feat(app): add internal components batch 6

This commit is contained in:
吴璨
2026-09-30 10:20:46 +08:00
parent 5bd126e026
commit baa1154cdd
10 changed files with 1731 additions and 0 deletions
+180
View File
@@ -0,0 +1,180 @@
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
}