Files
2026-09-30 10:20:46 +08:00

181 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
func runMergeFFmpeg(ctx context.Context, ffmpeg string, args []string, progress func(time.Duration)) error {
command := exec.CommandContext(ctx, ffmpeg, append([]string{
"-hide_banner", "-nostdin", "-nostats", "-loglevel", "error", "-progress", "pipe:1",
}, args...)...)
diagnostics := &cappedStringWriter{limit: 64 * 1024}
command.Stderr = diagnostics
output, err := command.StdoutPipe()
if err != nil {
return err
}
defer output.Close()
if err := command.Start(); err != nil {
return err
}
scanner := bufio.NewScanner(output)
for scanner.Scan() {
key, value, ok := strings.Cut(scanner.Text(), "=")
if ok && key == "out_time_us" && progress != nil {
if micros, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64); err == nil && micros >= 0 {
progress(time.Duration(micros) * time.Microsecond)
}
}
}
err = command.Wait()
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
return fmt.Errorf("FFmpeg 处理失败:%w %s", err, truncate(diagnostics.String(), 1500))
}
return scanner.Err()
}
func mergeMediaFiles(ctx context.Context, ffmpeg string, paths []string, output string, report func(int, string)) (string, error) {
if len(paths) == 0 {
return "", fmt.Errorf("没有可合并的分集")
}
lastProgress, lastDetail := -1, ""
notify := func(progress int, detail string) {
if progress > 99 {
progress = 99
}
if progress < lastProgress {
progress = lastProgress
}
if report != nil && (progress != lastProgress || detail != lastDetail) {
report(progress, detail)
}
lastProgress, lastDetail = progress, detail
}
metadata := make([]mergeMediaInfo, 0, len(paths))
var duration time.Duration
for index, path := range paths {
notify(index*5/len(paths), fmt.Sprintf("检测分集编码 %d/%d", index+1, len(paths)))
info, err := probeMergeMedia(ctx, ffmpeg, path)
if err != nil {
return "", err
}
metadata = append(metadata, info)
duration += info.duration
}
plan := planMergeMedia(metadata)
method := plan.description()
work, err := os.MkdirTemp(filepath.Dir(output), ".merge-*")
if err != nil {
return "", err
}
defer os.RemoveAll(work)
inputs := append([]string(nil), paths...)
preparedArgs := make([][]string, len(paths))
var preparedDuration, processed time.Duration
for index, step := range plan.steps {
if step.video || step.audio || step.remux {
preparedDuration += metadata[index].duration
extension := ".mp4"
if plan.transport {
extension = ".ts"
}
inputs[index] = filepath.Join(work, fmt.Sprintf("%06d%s", index+1, extension))
preparedArgs[index], err = plan.prepareArgs(paths[index], inputs[index], metadata[index], step)
if err != nil {
return "", fmt.Errorf("无法准备 %s:%w;原分集已保留", filepath.Base(paths[index]), err)
}
}
}
for index, step := range plan.steps {
if !step.video && !step.audio && !step.remux {
continue
}
action := "无损换封装"
if step.video {
action = "转换此集视频"
} else if step.audio {
action = "仅调整此集音轨"
}
detail := fmt.Sprintf("%s · %s %d/%d", method, action, index+1, len(paths))
notify(5+int(processed*60/preparedDuration), detail)
err := runMergeFFmpeg(ctx, ffmpeg, preparedArgs[index], func(elapsed time.Duration) {
if elapsed > metadata[index].duration {
elapsed = metadata[index].duration
}
notify(5+int((processed+elapsed)*60/preparedDuration), detail)
})
if err != nil {
return "", fmt.Errorf("处理 %s 失败:%w;原分集已保留", filepath.Base(paths[index]), err)
}
processed += metadata[index].duration
}
var manifest strings.Builder
manifest.WriteString("ffconcat version 1.0\n")
for index, path := range inputs {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
if strings.ContainsAny(absolute, "\r\n") {
return "", fmt.Errorf("分集路径包含换行符,无法合并")
}
fmt.Fprintf(&manifest, "file '%s'\n", strings.ReplaceAll(filepath.ToSlash(absolute), "'", "'\\''"))
if plan.transport {
fmt.Fprintf(&manifest, "duration %.6f\n", metadata[index].duration.Seconds())
}
}
list := filepath.Join(work, "inputs.ffconcat")
if err := os.WriteFile(list, []byte(manifest.String()), 0600); err != nil {
return "", err
}
notify(65, method)
args := []string{"-protocol_whitelist", "file,pipe", "-f", "concat", "-safe", "0", "-i", list,
"-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn", "-c", "copy", "-video_track_timescale", strconv.Itoa(plan.timescale), "-movflags", "+faststart"}
if plan.parameters {
if plan.video.videoCodec == "h264" {
args = append(args, "-tag:v", "avc3")
} else if plan.video.videoCodec == "hevc" {
args = append(args, "-tag:v", "hev1")
}
}
args = append(args, "-y", output)
err = runMergeFFmpeg(ctx, ffmpeg, args, func(elapsed time.Duration) {
notify(65+int(elapsed*15/duration), method)
})
if err != nil {
return "", err
}
notify(80, "核对合并文件时长")
merged, err := probeMergeMedia(ctx, ffmpeg, output)
if err != nil {
return "", err
}
tolerance := duration / 100
if tolerance < 2*time.Second {
tolerance = 2 * time.Second
}
if merged.duration < duration-tolerance || merged.duration > duration+tolerance {
return "", fmt.Errorf("合并文件时长不符(预计 %.1f 秒,实际 %.1f 秒),原分集已保留", duration.Seconds(), merged.duration.Seconds())
}
notify(80, "校验完整视频和音轨(只解码,不转码)")
err = runMergeFFmpeg(ctx, ffmpeg, []string{"-xerror", "-err_detect", "explode", "-threads", "2", "-protocol_whitelist", "file,pipe", "-i", output,
"-map", "0:v:0", "-map", "0:a:0?", "-threads:v", "2", "-fps_mode", "passthrough", "-abort_on", "empty_output", "-f", "null", "-"}, func(elapsed time.Duration) {
notify(80+int(elapsed*19/duration), "校验完整视频和音轨(只解码,不转码)")
})
if err != nil {
return "", fmt.Errorf("合并文件解码校验失败:%w;原分集已保留", err)
}
return method, nil
}