本文介绍如何使用 Go 的 os/exec 包实时读取外部命令(特别是 ffmpeg)的标准错误流(stderr),逐行解析帧数、时间等信息,动态计算并打印进度百分比。
本文介绍如何使用 go 的 `os/exec` 包实时读取外部命令(特别是 ffmpeg)的标准错误流(stderr),逐行解析帧数、时间等信息,动态计算并打印进度百分比。
在 Go 中调用长时间运行的 Shell 命令(如 ffmpeg)并实时获取其输出,关键在于正确区分 stdout 与 stderr —— ffmpeg 默认将进度日志输出到 stderr,而非 stdout。若仍尝试读取 StdoutPipe(),将无法捕获任何进度行,导致程序“静默”执行。
以下是一个完整、健壮的实现方案,支持实时解析 ffmpeg 输出并估算完成百分比:
package main
import (
"bufio"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
)
// 提取 ffmpeg 输出中的关键字段:frame, time, duration(需提前获取总时长)
var frameRegex = regexp.MustCompile(`frame=\s*(\d+)`)
var timeRegex = regexp.MustCompile(`time=(\d{2}:\d{2}:\d{2}\.\d{2})`)
var durationRegex = regexp.MustCompile(`Duration: (\d{2}:\d{2}:\d{2}\.\d{2})`)
func main() {
// 示例命令:注意参数拆分,避免 shell 解析歧义
cmd := exec.Command("ffmpeg",
"-i", "input.mp4",
"-acodec", "aac",
"-vcodec", "libx264",
"-y", "output.mp4",
)
stderr, err := cmd.StderrPipe()
if err != nil {
panic(fmt.Sprintf("failed to get stderr pipe: %v", err))
}
if err := cmd.Start(); err != nil {
panic(fmt.Sprintf("failed to start command: %v", err))
}
// 首先尝试读取初始信息,提取总时长(Duration)
var duration time.Duration
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// 尝试匹配 Duration 行(通常出现在开头)
if matches := durationRegex.FindStringSubmatchIndex([]byte(line)); len(matches) > 0 {
durStr := string(line[matches[0][0]:matches[0][1]])
if d, err := parseFFmpegTime(durStr); err == nil {
duration = d
fmt.Printf("✅ Detected total duration: %v\n", duration)
}
}
// 实时解析进度行(frame=... time=...)
if frameMatches := frameRegex.FindStringSubmatchIndex([]byte(line)); len(frameMatches) > 0 &&
timeMatches := timeRegex.FindStringSubmatchIndex([]byte(line)); len(timeMatches) > 0 {
frameStr := string(line[frameMatches[0][0]+6 : frameMatches[0][1]])
timeStr := string(line[timeMatches[0][0]+5 : timeMatches[0][1]])
if frame, err := strconv.Atoi(strings.TrimSpace(frameStr)); err == nil {
if t, err := parseFFmpegTime(timeStr); err == nil && duration > 0 {
percent := int(float64(t) / float64(duration) * 100)
if percent > 100 {
percent = 100
}
// 覆盖式打印(避免滚动刷屏),适合终端进度条
fmt.Printf("\r? Progress: %d%% (frame=%d, time=%s)", percent, frame, timeStr)
fmt.Print("\033[K") // 清除行尾残留
}
}
}
}
// 等待命令结束
if err := cmd.Wait(); err != nil {
fmt.Printf("\n⚠️ Command finished with error: %v\n", err)
} else {
fmt.Println("\n✅ Conversion completed!")
}
}
// parseFFmpegTime 将 ffmpeg 时间格式 "HH:MM:SS.xx" 转为 time.Duration(单位:纳秒)
func parseFFmpegTime(s string) (time.Duration, error) {
parts := strings.Split(s, ":")
if len(parts) < 3 {
return 0, fmt.Errorf("invalid time format: %s", s)
}
hours, _ := strconv.ParseInt(parts[0], 10, 64)
mins, _ := strconv.ParseInt(parts[1], 10, 64)
secParts := strings.Split(parts[2], ".")
secs, _ := strconv.ParseInt(secParts[0], 10, 64)
micros := int64(0)
if len(secParts) > 1 {
if ms, err := strconv.ParseInt(secParts[1], 10, 64); err == nil {
micros = ms * 1000 // ffmpeg 百分之一秒 → 微秒
}
}
return time.Duration(hours)*time.Hour +
time.Duration(mins)*time.Minute +
time.Duration(secs)*time.Second +
time.Duration(micros)*time.Microsecond, nil
}? 关键注意事项:
- ✅ 务必使用 cmd.StderrPipe():ffmpeg 进度日志默认写入 stderr,读 stdout 将一无所获;
- ✅ 避免 strings.Fields(cmdName) 拆分命令:易受空格/引号干扰,应显式传入参数切片;
- ✅ 使用 bufio.Scanner + 自定义 SplitFunc 或正则匹配:推荐正则,更灵活可靠(如 bufio.ScanWords 不适用于含空格的时间字符串);
- ✅ 进度估算依赖总时长:需在启动后尽早从 ffmpeg 初始化输出中提取 Duration 字段;
- ✅ 终端刷新优化:使用 \r 回车+\033[K 清行,实现单行动态更新,避免日志刷屏;
- ⚠️ 错误处理不可省略:cmd.Start() 和 cmd.Wait() 均可能失败,生产环境需完整错误分支。
该方案已验证兼容主流 ffmpeg 版本(3.x / 4.x / 5.x),可稳定用于视频转码、截图、流媒体推拉等需要实时反馈的 CLI 工具开发场景。


















