一堆 `.ts` 分片合并后音画不同步?从问题定位到通用修复脚本的完整实战

一、问题现象:为什么一堆 .ts 合起来就出事了

第一次处理 .ts 分片视频时,很多人的直觉都很简单:

既然这些分片是连续的,那按顺序拼起来不就行了?

于是最自然的思路就是:

text 复制代码
001.ts + 002.ts + 003.ts + ... = output.mp4

但现实经常不是这样。

实际处理中,常见现象通常有这些:

  • 文件合成出来了,但音画不同步
  • 前面播放还正常,后面开始越播越偏
  • 生成了 MP4,但播放器打不开
  • 命令执行成功,结果视频时长却只有十几秒
  • 换一种合并方式,结果又完全不一样

这些现象第一次遇到时,很容易让人误判成:

  • 文件顺序错了
  • 某几个分片坏了
  • ffmpeg 命令写错了
  • 播放器兼容性有问题

但真正把这个问题深入挖下去之后,你会发现:

这不是简单的文件拼接问题,而是一个典型的媒体时间轴问题。

二、表面上是在合并文件,本质上是在重建时间线

从操作系统角度看,.ts 当然是文件。

但从播放器和封装器角度看,真正重要的根本不是"文件 A 接文件 B",而是:

  • 视频帧什么时候显示
  • 音频包什么时候播放
  • 音视频之间是不是保持同一条时间线
  • 分片边界处的时间信息是不是连续
  • 封装后的容器还能不能被正常解码

也就是说,你以为自己在做的是:

把很多个小文件拼成一个大文件

播放器真正关心的是:

这批分片里的音频和视频,最终能不能落在一条连续、单调、可播放的时间线上

这就是为什么很多"看起来已经合成成功"的视频,最终表现却是:

  • 音画不同步
  • 时长不完整
  • 甚至根本无法播放

所以,这类问题的核心从来不是:

文件有没有拼上

而是:

时间轴有没有被正确重建

三、为什么 .ts 分片特别容易出现音画同步问题

.ts 是直播、HLS、m3u8 场景下最常见的分片格式之一。

它的优点很明显:

  • 适合流式分发
  • 便于切片传输
  • 在网络场景下容错性较好
  • 适合服务端动态组织播放列表

但也正因为它是"传输流"格式,所以它天然不是那种"拼上就稳"的素材。

每个 .ts 分片内部不仅包含音视频数据,还带有自己的时间信息,例如:

  • 视频的显示时间戳 PTS
  • 视频的解码时间戳 DTS
  • 音频流的起始时间
  • 音频包的持续时间
  • 分片内部的局部播放节奏

单独播放某一个 .ts 分片时,这些问题可能根本不明显,因为播放器只需要处理这一个片段的局部时间线。

但当你把几百个分片合成一个完整视频时,播放器面对的就不再是"局部时间轴",而是:

一条完整的全局时间轴

只要分片边界处有一点点不一致,问题就会在最终播放时被放大。

四、这次真实遇到的根因:不是坏片,而是系统性轻微偏移

这次处理的这批素材,并不是那种严重损坏的 TS 分片。

没有出现特别极端的问题,例如:

  • 时间戳大幅回退
  • 某一段完全没有音频或视频
  • 分片全部从 0 重新开始
  • 某几段突然跳秒数秒

真正的问题反而更隐蔽,也更具有代表性:

每个分片里,音频起点整体比视频起点略早。

实际分析结果大致如下:

  • 音频相对视频提前约 62ms 到 80ms
  • 平均偏移大约 70ms
  • 音频分片边界还存在约 1ms 内的轻微抖动

这意味着问题并不是:

某一段坏了

而是:

整批分片都带着相同方向的轻微时间偏差

这种问题最讨厌的地方就在于:

  • 单独播放一段,几乎不明显
  • 合并成长视频后,偏差会逐渐放大
  • 如果处理方式不对,封装之后问题会进一步恶化

所以这类问题的本质,不是"坏片",而是:

系统性轻微时间偏移 + 分片边界抖动

五、为什么简单 concat 很容易踩坑

网上很多教程会直接给出类似命令:

bash 复制代码
ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4

这条命令在一些很规整的素材上确实能跑通,但在 HLS / 直播来源的 .ts 分片场景下,它并不是稳定解。

主要原因有两个。

1. concat 对边界时间轴更敏感

concat 会让 ffmpeg 按顺序读取多个分片并尝试接续播放。

如果边界处时间戳不够规整,ffmpeg 在处理过程中就可能出现:

  • 提前认为输入结束
  • 跳过后续异常数据
  • 只保留前面一部分有效片段

这就是为什么有时看起来"执行成功",结果输出文件时长却明显不完整。

2. -c copy 会保留原始问题

如果再叠加 -c copy,那就意味着:

  • 视频流不重新处理
  • 音频流也尽量原样保留
  • 原始输入中的时间线问题会被直接继承

结果就可能变成:

  • 文件有了
  • 但不代表可播
  • 更不代表同步正常

所以后来在通用脚本里,我没有把 concat + copy 作为默认方案,而是把它放到可选策略里,并且通过自动回退机制限制风险。

六、为什么"先拼成 merged.ts 再处理"通常更稳

后来我切换成了另一种思路:

text 复制代码
所有 ts 分片 -> 顺序拼成 merged.ts -> 再统一交给 ffmpeg 处理

这个思路乍一看有点"粗暴",但在真实世界的这类 TS 素材中,它通常反而更稳。

原因在于:

  • concat 面对的是很多段各自带边界的小流
  • merged.ts 面对的是一条已经连续组织过的整体输入流

虽然原始问题并不会凭空消失,但至少 ffmpeg 不需要在输入阶段不断处理:

  • 分片边界
  • 分片切换
  • 多段局部时间线拼接

所以这两种方式的现实差异通常是:

  • concat 更快
  • binary -> merged.ts 更稳

最终脚本里,我保留了两种方式,并让它们成为可选、可回退的策略,而不是强行写死一种。

七、真正要修的不是"文件",而是"时间线"

如果把整个修复过程浓缩成一句话,那就是:

不要把 .ts 合并当成文件拼接问题,而要把它当成媒体时间线修复问题。

也正因为这样,最终脚本的整体流程不是简单的:

text 复制代码
收集文件 -> 合并 -> 输出

而是:

text 复制代码
收集分片 -> 分析偏移 -> 选择策略 -> 重建时间线 -> 校验输出

换句话说,前半段是在"诊断问题",后半段才是在"修复问题"。

八、脚本整体结构设计

最终稳定版脚本 universal_ts_merge_sync_optimized_fixed.py,整体分成了几个清晰层次:

1. 输入层

支持:

  • zip 包输入
  • 已解压目录输入

核心代码:

python 复制代码
def extract_if_zip(input_path: Path, workdir: Path) -> Path:
    if input_path.is_file() and input_path.suffix.lower() == ".zip":
        target = workdir / "extracted"
        target.mkdir(parents=True, exist_ok=True)
        with zipfile.ZipFile(input_path, "r") as zf:
            zf.extractall(target)
        return target
    return input_path

2. 分片收集与排序层

排序时必须用自然排序,而不能直接按字符串排序:

python 复制代码
def natural_sort_key(path_obj: Path):
    s = path_obj.name
    return [int(x) if x.isdigit() else x.lower() for x in re.split(r"(\d+)", s)]


def find_ts_files(root: Path):
    files = sorted(root.rglob("*.ts"), key=natural_sort_key)
    if not files:
        raise FileNotFoundError(f"未找到 ts 文件: {root}")
    return files

原因很简单,字符串排序会把:

text 复制代码
1.ts
10.ts
100.ts
11.ts
2.ts

排错顺序,而视频分片只要顺序乱了,后面所有处理都没意义。

3. 参数分析层

这一层通过 ffprobe 分析每个分片的:

  • 视频起点
  • 音频起点
  • 时长
  • 帧率

核心代码:

python 复制代码
def ffprobe_stream_times(media_path: Path):
    result = run_cmd(
        [
            "ffprobe",
            "-v", "error",
            "-show_entries", "stream=codec_type,start_time,duration,avg_frame_rate,r_frame_rate",
            "-of", "json",
            str(media_path),
        ],
        check=True,
    )
    data = json.loads(result.stdout) if result.stdout.strip() else {}

    vstart = astart = vdur = adur = None
    fps = None

    for s in data.get("streams", []):
        if s.get("codec_type") == "video" and vstart is None:
            vstart = float(s["start_time"])
            vdur = float(s["duration"])
            rate = s.get("avg_frame_rate") or s.get("r_frame_rate")
            if rate and rate != "0/0":
                try:
                    n, d = rate.split("/")
                    n = float(n)
                    d = float(d)
                    if d != 0:
                        fps = n / d
                except Exception:
                    pass
        elif s.get("codec_type") == "audio" and astart is None:
            astart = float(s["start_time"])
            adur = float(s["duration"])

    return {
        "video_start_s": vstart,
        "audio_start_s": astart,
        "video_dur_s": vdur,
        "audio_dur_s": adur,
        "fps": fps,
    }

这里最关键的分析值是:

python 复制代码
row["av_offset_ms"] = (astart - vstart) * 1000.0

它的意义是:

  • 小于 0:音频更早
  • 大于 0:音频更晚

后面的自动音频修正,就是基于这个值推导出来的。

九、为什么要做并发分析

如果有几百个 .ts 分片,串行执行 ffprobe 会比较慢,所以后面加了并发分析:

python 复制代码
with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
    for idx, ts in sample:
        future = executor.submit(ffprobe_stream_times, ts)
        future_map[future] = (idx, ts)

    for future in as_completed(future_map):
        idx, ts = future_map[future]
        info = future.result()
        rows_map[idx] = {
            "index": idx,
            "file": ts.name,
            "video_start_s": info["video_start_s"],
            "audio_start_s": info["audio_start_s"],
            "video_dur_s": info["video_dur_s"],
            "audio_dur_s": info["audio_dur_s"],
            "fps": info["fps"],
        }

这里需要特别强调:

并发只影响分析速度,不影响最终视频时长。

之前某一版脚本中出现过"加了优化之后视频时长异常"的情况,真正原因不是并发,而是:

  • quick 分支把分析关了
  • 同时默认走了更脆弱的 concat

真正导致时长异常的是后者,不是并发本身。

十、为什么需要 full / sample / off 三种分析模式

因为不是所有场景都值得做全量分析。

python 复制代码
def build_probe_sample(ts_files, analyze_mode: str):
    if analyze_mode == "off":
        return []
    if analyze_mode == "full" or len(ts_files) <= 30:
        return list(enumerate(ts_files, 1))

full

分析全部分片,最完整,适合调试和高精度诊断。

sample

只分析头、中、尾部分分片,速度更快,适合大多数实际场景。

off

完全不分析,适合你已经非常清楚素材情况时使用。

在真实工程里,sample 是最实用的折中选择。

十一、音频修正的核心逻辑

脚本里处理音频同步的核心代码是:

python 复制代码
def build_audio_filter(delay_ms=0):
    base = "aresample=async=1:first_pts=0"
    if delay_ms == 0:
        return base
    if delay_ms > 0:
        return f"adelay={delay_ms}|{delay_ms},{base}"
    trim_sec = abs(delay_ms) / 1000.0
    return f"atrim=start={trim_sec},asetpts=PTS-STARTPTS,{base}"

这段逻辑做了三件事:

1. first_pts=0

统一音频时间起点,避免继承原始分片里不规整的起始时间。

2. async=1

允许 ffmpeg 自动吸收边界处非常小的时间漂移。

3. adelay / atrim

用来修正固定偏移:

  • 音频早了,就延后
  • 音频晚了,就裁掉前面并重建时间戳

自动偏移计算逻辑则是:

python 复制代码
def auto_audio_delay(user_delay_ms, summary):
    if user_delay_ms is not None:
        return int(user_delay_ms)
    return int(round(-summary["offset_mean_ms"]))

比如平均偏移是 -70ms,就自动转成:

  • delay_ms = 70

也就是把音频整体往后推 70ms。

十二、为什么最终大多数情况下还是建议直接 reencode

很多人对重编码有天然抗拒,因为它慢。

但在这类 .ts 分片问题里,reencode 往往才是最稳的终点。

核心代码:

python 复制代码
def reencode_fix(source_kind: str, source_path: Path, output_mp4: Path, fps=25.0, encoder="libx264", preset="fast", quality=20, audio_delay_ms=0):
    cmd = [
        "ffmpeg",
        "-y",
        "-fflags", "+genpts+discardcorrupt",
        "-err_detect", "ignore_err",
        *build_input_args(source_kind, source_path),
        "-map", "0:v:0",
        "-map", "0:a:0?",
        "-fps_mode", "cfr",
        "-r", str(fps),
        *build_video_encode_args(encoder, preset, quality),
        "-c:a", "aac",
        "-b:a", "192k",
        "-ar", "48000",
        "-af", build_audio_filter(audio_delay_ms),
        "-movflags", "+faststart",
        str(output_mp4),
    ]
    run_cmd(cmd)

这一段实际做了这些事情:

  • 重新生成时间戳
  • 统一帧率
  • 重新编码视频为标准 H.264
  • 重新编码音频为标准 AAC
  • 输出更兼容播放器的 MP4 容器

本质上,这一步是在"洗干净"原始流里的不规整状态。

所以虽然慢,但往往最稳。

十三、为什么必须做输出校验

这是很多人写类似脚本时最容易忽略的一层。

很多时候,ffmpeg 不报错,只说明:

  • 命令执行完了
  • 文件写出来了

并不等于:

  • 文件真的有视频流
  • 时长有效
  • 播放器一定能播
  • 解码路径没问题

所以脚本里专门加了输出校验:

python 复制代码
def validate_media(path: Path):
    if not path.exists() or path.stat().st_size < 1024 * 100:
        return False, "输出文件不存在或过小"

    try:
        data = ffprobe_json(path)
    except Exception as e:
        return False, f"ffprobe失败: {e}"

    streams = data.get("streams", [])
    has_video = any(s.get("codec_type") == "video" for s in streams)
    if not has_video:
        return False, "未检测到视频流"

然后继续做:

  • duration 检查
  • 1 秒抽样解码验证

这样做的目的只有一个:

避免得到"看起来有文件,实际上不可播"的假成功结果。

十四、自动回退机制,才是脚本真正工程化的地方

这套脚本最关键的地方,不是某一条 ffmpeg 命令,而是"失败之后怎么继续往下走"。

核心代码:

python 复制代码
def process_with_fallback(
    ts_files,
    sources,
    output_path,
    mode,
    join_method,
    fps,
    encoder,
    preset,
    quality,
    delay_ms,
    skip_validate=False,
):

这层逻辑的意义在于:

  1. 先按当前模式尝试
  2. 如果失败,升级到更稳策略
  3. 如果还失败,切换输入组织方式
  4. 一直到得到可通过校验的结果

这种设计比"写死一条最优命令"更符合真实工程。

因为你无法保证所有 .ts 素材都来自同一种干净来源,真正稳定的脚本,必须有策略链和回退路径。

十五、最终推荐使用方式

稳定优先

bash 复制代码
python universal_ts_merge_sync_optimized_fixed.py input.zip output.mp4 --mode reencode --preset fast

NVIDIA 显卡加速

bash 复制代码
python universal_ts_merge_sync_optimized_fixed.py input.zip output.mp4 --mode reencode --encoder h264_nvenc --preset p5

已知音频整体提前约 70ms

bash 复制代码
python universal_ts_merge_sync_optimized_fixed.py input.zip output.mp4 --mode reencode --audio-delay-ms 70

十六、最终结论

回到最开始的问题:

为什么一堆 .ts 分片合并后,会出现音画不同步、时长异常,甚至无法播放?

真正的答案是:

你处理的不是一堆普通文件,而是一堆带着各自时间信息的媒体分片。

所以真正要修的,从来不是"文件有没有拼上",而是:

  • 音频和视频是不是在同一条时间线上
  • 分片边界是不是被正确吸收
  • 最终输出容器是不是足够规整
  • 播放器能不能真正解码和播放这个结果

把视角从"文件拼接"切换到"时间线重建"之后,这类问题就会突然变得非常清晰。

十七、完整代码

下面给出最终可用版完整脚本。

如果你希望直接挂源码文件,也可以使用这份带适度注释的版本:

下载适度注释版源码

python 复制代码
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import csv
import json
import re
import shutil
import statistics
import subprocess
import sys
import tempfile
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path


BUFFER_SIZE = 8 * 1024 * 1024


def run_cmd(cmd, check=True):
    result = subprocess.run(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
    )
    if result.stdout:
        print(result.stdout)
    if result.stderr:
        print(result.stderr, file=sys.stderr)
    if check and result.returncode != 0:
        raise RuntimeError("命令执行失败: " + " ".join(str(x) for x in cmd))
    return result


def ensure_tools():
    for exe in ("ffmpeg", "ffprobe"):
        if shutil.which(exe) is None:
            raise EnvironmentError(f"未找到 {exe}")


def human_size(num_bytes: int) -> str:
    units = ["B", "KB", "MB", "GB", "TB"]
    size = float(num_bytes)
    for unit in units:
        if size < 1024 or unit == units[-1]:
            return f"{size:.2f}{unit}"
        size /= 1024


def natural_sort_key(path_obj: Path):
    s = path_obj.name
    return [int(x) if x.isdigit() else x.lower() for x in re.split(r"(\d+)", s)]


def extract_if_zip(input_path: Path, workdir: Path) -> Path:
    if input_path.is_file() and input_path.suffix.lower() == ".zip":
        target = workdir / "extracted"
        target.mkdir(parents=True, exist_ok=True)
        with zipfile.ZipFile(input_path, "r") as zf:
            zf.extractall(target)
        return target
    return input_path


def find_ts_files(root: Path):
    files = sorted(root.rglob("*.ts"), key=natural_sort_key)
    if not files:
        raise FileNotFoundError(f"未找到 ts 文件: {root}")
    return files


def escape_concat_path(path: Path) -> str:
    s = path.resolve().as_posix()
    return s.replace("'", r"'\''")


def ffprobe_json(path: Path):
    result = run_cmd(
        [
            "ffprobe",
            "-v", "error",
            "-show_streams",
            "-show_format",
            "-of", "json",
            str(path),
        ],
        check=True,
    )
    return json.loads(result.stdout) if result.stdout.strip() else {}


def ffprobe_stream_times(media_path: Path):
    result = run_cmd(
        [
            "ffprobe",
            "-v", "error",
            "-show_entries", "stream=codec_type,start_time,duration,avg_frame_rate,r_frame_rate",
            "-of", "json",
            str(media_path),
        ],
        check=True,
    )
    data = json.loads(result.stdout) if result.stdout.strip() else {}

    vstart = astart = vdur = adur = None
    fps = None

    for s in data.get("streams", []):
        if s.get("codec_type") == "video" and vstart is None:
            vstart = float(s["start_time"])
            vdur = float(s["duration"])
            rate = s.get("avg_frame_rate") or s.get("r_frame_rate")
            if rate and rate != "0/0":
                try:
                    n, d = rate.split("/")
                    n = float(n)
                    d = float(d)
                    if d != 0:
                        fps = n / d
                except Exception:
                    pass
        elif s.get("codec_type") == "audio" and astart is None:
            astart = float(s["start_time"])
            adur = float(s["duration"])

    return {
        "video_start_s": vstart,
        "audio_start_s": astart,
        "video_dur_s": vdur,
        "audio_dur_s": adur,
        "fps": fps,
    }


def build_probe_sample(ts_files, analyze_mode: str):
    if analyze_mode == "off":
        return []

    # sample 模式只取头、中、尾三段,兼顾速度和代表性
    if analyze_mode == "full" or len(ts_files) <= 30:
        return list(enumerate(ts_files, 1))

    n = len(ts_files)
    idxs = set()

    for i in range(min(10, n)):
        idxs.add(i)

    mid_start = max(0, n // 2 - 5)
    for i in range(mid_start, min(mid_start + 10, n)):
        idxs.add(i)

    for i in range(max(0, n - 10), n):
        idxs.add(i)

    picked = sorted(idxs)
    return [(i + 1, ts_files[i]) for i in picked]


def analyze_ts_files(ts_files, workers=8, analyze_mode="full"):
    sample = build_probe_sample(ts_files, analyze_mode)

    if not sample:
        summary = {
            "count": len(ts_files),
            "sample_count": 0,
            "offset_min_ms": 0.0,
            "offset_max_ms": 0.0,
            "offset_mean_ms": 0.0,
            "offset_median_ms": 0.0,
            "offset_abs_max_ms": 0.0,
            "video_gap_abs_max_ms": 0.0,
            "audio_gap_abs_max_ms": 0.0,
            "fps_guess": 25.0,
        }
        return {"rows": [], "summary": summary}

    rows_map = {}
    future_map = {}

    # 分片较多时,ffprobe 串行探测会比较慢,这里用并发方式收集基础参数
    with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
        for idx, ts in sample:
            future = executor.submit(ffprobe_stream_times, ts)
            future_map[future] = (idx, ts)

        for future in as_completed(future_map):
            idx, ts = future_map[future]
            info = future.result()
            rows_map[idx] = {
                "index": idx,
                "file": ts.name,
                "video_start_s": info["video_start_s"],
                "audio_start_s": info["audio_start_s"],
                "video_dur_s": info["video_dur_s"],
                "audio_dur_s": info["audio_dur_s"],
                "fps": info["fps"],
            }

    rows = []
    prev = None

    for idx in sorted(rows_map):
        row = rows_map[idx]
        vstart = row["video_start_s"]
        astart = row["audio_start_s"]
        vdur = row["video_dur_s"]
        adur = row["audio_dur_s"]

        if vstart is None or astart is None or vdur is None or adur is None:
            raise ValueError(f"文件缺少音频或视频流: {row['file']}")

        row["av_offset_ms"] = (astart - vstart) * 1000.0
        row["video_end_s"] = vstart + vdur
        row["audio_end_s"] = astart + adur
        row["video_gap_prev_ms"] = None
        row["audio_gap_prev_ms"] = None

        if prev is not None:
            row["video_gap_prev_ms"] = (row["video_start_s"] - prev["video_end_s"]) * 1000.0
            row["audio_gap_prev_ms"] = (row["audio_start_s"] - prev["audio_end_s"]) * 1000.0

        rows.append(row)
        prev = row

    offsets = [r["av_offset_ms"] for r in rows]
    video_gaps = [r["video_gap_prev_ms"] for r in rows[1:] if r["video_gap_prev_ms"] is not None]
    audio_gaps = [r["audio_gap_prev_ms"] for r in rows[1:] if r["audio_gap_prev_ms"] is not None]
    fps_values = [r["fps"] for r in rows if r["fps"] and r["fps"] > 1]

    summary = {
        "count": len(ts_files),
        "sample_count": len(rows),
        "offset_min_ms": min(offsets) if offsets else 0.0,
        "offset_max_ms": max(offsets) if offsets else 0.0,
        "offset_mean_ms": statistics.mean(offsets) if offsets else 0.0,
        "offset_median_ms": statistics.median(offsets) if offsets else 0.0,
        "offset_abs_max_ms": max(abs(x) for x in offsets) if offsets else 0.0,
        "video_gap_abs_max_ms": max(abs(x) for x in video_gaps) if video_gaps else 0.0,
        "audio_gap_abs_max_ms": max(abs(x) for x in audio_gaps) if audio_gaps else 0.0,
        "fps_guess": round(statistics.median(fps_values), 3) if fps_values else 25.0,
    }

    return {"rows": rows, "summary": summary}


def write_report(report_dir: Path, analysis):
    report_dir.mkdir(parents=True, exist_ok=True)

    csv_path = report_dir / "ts_av_offset_report.csv"
    json_path = report_dir / "ts_av_offset_summary.json"

    with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(
            f,
            fieldnames=[
                "index",
                "file",
                "video_start_s",
                "audio_start_s",
                "av_offset_ms",
                "video_dur_s",
                "audio_dur_s",
                "video_end_s",
                "audio_end_s",
                "video_gap_prev_ms",
                "audio_gap_prev_ms",
                "fps",
            ],
        )
        writer.writeheader()
        writer.writerows(analysis["rows"])

    json_path.write_text(
        json.dumps(analysis["summary"], ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return csv_path, json_path


def concat_ts_files_binary(ts_files, merged_ts_path: Path):
    # 先拼成一条连续 TS,通常比直接 concat 更稳
    with merged_ts_path.open("wb") as fout:
        for ts in ts_files:
            with ts.open("rb") as fin:
                shutil.copyfileobj(fin, fout, length=BUFFER_SIZE)


def build_concat_list(ts_files, list_path: Path):
    with list_path.open("w", encoding="utf-8", newline="\n") as f:
        for ts in ts_files:
            f.write(f"file '{escape_concat_path(ts)}'\n")


def build_audio_filter(delay_ms=0):
    # first_pts=0 统一音频起点;async=1 吸收边界处的轻微漂移
    base = "aresample=async=1:first_pts=0"
    if delay_ms == 0:
        return base
    if delay_ms > 0:
        return f"adelay={delay_ms}|{delay_ms},{base}"
    trim_sec = abs(delay_ms) / 1000.0
    return f"atrim=start={trim_sec},asetpts=PTS-STARTPTS,{base}"


def build_video_encode_args(encoder: str, preset: str, quality: int):
    encoder = encoder.lower()

    if encoder == "libx264":
        return [
            "-c:v", "libx264",
            "-preset", preset,
            "-crf", str(quality),
            "-pix_fmt", "yuv420p",
            "-profile:v", "high",
            "-level", "4.1",
        ]

    if encoder == "h264_nvenc":
        nvenc_preset = preset if preset.startswith("p") else "p5"
        return [
            "-c:v", "h264_nvenc",
            "-preset", nvenc_preset,
            "-cq", str(quality),
            "-pix_fmt", "yuv420p",
        ]

    raise ValueError(f"不支持的视频编码器: {encoder}")


def build_input_args(source_kind: str, source_path: Path):
    if source_kind == "concat":
        return ["-f", "concat", "-safe", "0", "-i", str(source_path)]
    if source_kind == "binary":
        return ["-i", str(source_path)]
    raise ValueError(f"未知输入类型: {source_kind}")


def remux_fast(source_kind: str, source_path: Path, output_mp4: Path):
    cmd = [
        "ffmpeg",
        "-y",
        "-fflags", "+genpts+discardcorrupt",
        "-err_detect", "ignore_err",
        *build_input_args(source_kind, source_path),
        "-map", "0:v:0",
        "-map", "0:a:0?",
        "-c:v", "copy",
        "-c:a", "copy",
        "-bsf:a", "aac_adtstoasc",
        "-movflags", "+faststart",
        "-avoid_negative_ts", "make_zero",
        str(output_mp4),
    ]
    run_cmd(cmd)


def remux_audiofix(source_kind: str, source_path: Path, output_mp4: Path, audio_delay_ms=0):
    cmd = [
        "ffmpeg",
        "-y",
        "-fflags", "+genpts+discardcorrupt",
        "-err_detect", "ignore_err",
        *build_input_args(source_kind, source_path),
        "-map", "0:v:0",
        "-map", "0:a:0?",
        "-c:v", "copy",
        "-c:a", "aac",
        "-b:a", "192k",
        "-ar", "48000",
        "-af", build_audio_filter(audio_delay_ms),
        "-movflags", "+faststart",
        "-avoid_negative_ts", "make_zero",
        str(output_mp4),
    ]
    run_cmd(cmd)


def reencode_fix(source_kind: str, source_path: Path, output_mp4: Path, fps=25.0, encoder="libx264", preset="fast", quality=20, audio_delay_ms=0):
    cmd = [
        "ffmpeg",
        "-y",
        "-fflags", "+genpts+discardcorrupt",
        "-err_detect", "ignore_err",
        *build_input_args(source_kind, source_path),
        "-map", "0:v:0",
        "-map", "0:a:0?",
        "-fps_mode", "cfr",
        "-r", str(fps),
        *build_video_encode_args(encoder, preset, quality),
        "-c:a", "aac",
        "-b:a", "192k",
        "-ar", "48000",
        "-af", build_audio_filter(audio_delay_ms),
        "-movflags", "+faststart",
        str(output_mp4),
    ]
    run_cmd(cmd)


def validate_media(path: Path):
    if not path.exists() or path.stat().st_size < 1024 * 100:
        return False, "输出文件不存在或过小"

    try:
        data = ffprobe_json(path)
    except Exception as e:
        return False, f"ffprobe失败: {e}"

    streams = data.get("streams", [])
    has_video = any(s.get("codec_type") == "video" for s in streams)
    if not has_video:
        return False, "未检测到视频流"

    try:
        duration = float(data.get("format", {}).get("duration", "0"))
    except Exception:
        duration = 0.0

    if duration <= 0:
        return False, "duration 无效"

    # 抽样解码 1 秒,避免得到"看起来有文件,实际不可播"的结果
    decode_check = subprocess.run(
        ["ffmpeg", "-v", "error", "-i", str(path), "-t", "1", "-f", "null", "-"],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        errors="replace",
    )
    if decode_check.returncode != 0:
        return False, "抽样解码失败"

    return True, "ok"


def choose_mode(mode: str, summary, quick=False):
    if mode != "auto":
        return mode

    if quick:
        return "reencode"

    offset_abs = abs(summary["offset_mean_ms"])
    vgap_abs = summary["video_gap_abs_max_ms"]
    agap_abs = summary["audio_gap_abs_max_ms"]

    if vgap_abs > 3.0 or agap_abs > 3.0:
        return "reencode"
    if offset_abs >= 20.0:
        return "audiofix"
    return "reencode"


def choose_join_method(join_method: str, quick=False):
    if join_method != "auto":
        return join_method
    return "binary"


def auto_audio_delay(user_delay_ms, summary):
    if user_delay_ms is not None:
        return int(user_delay_ms)
    return int(round(-summary["offset_mean_ms"]))


def build_sources(ts_files, workdir: Path):
    concat_list = workdir / "concat_list.txt"
    build_concat_list(ts_files, concat_list)

    merged_ts = workdir / "merged.ts"
    return {
        "concat": concat_list,
        "binary": merged_ts,
    }


def ensure_binary_source_if_needed(ts_files, sources):
    merged_ts = sources["binary"]
    if not merged_ts.exists():
        concat_ts_files_binary(ts_files, merged_ts)
    return merged_ts


def run_plan(plan_mode, source_kind, source_path, output_path, fps, encoder, preset, quality, delay_ms):
    if plan_mode == "fast":
        remux_fast(source_kind, source_path, output_path)
    elif plan_mode == "audiofix":
        remux_audiofix(source_kind, source_path, output_path, audio_delay_ms=delay_ms)
    else:
        reencode_fix(
            source_kind,
            source_path,
            output_path,
            fps=fps,
            encoder=encoder,
            preset=preset,
            quality=quality,
            audio_delay_ms=delay_ms,
        )


def process_with_fallback(
    ts_files,
    sources,
    output_path,
    mode,
    join_method,
    fps,
    encoder,
    preset,
    quality,
    delay_ms,
    skip_validate=False,
):
    tried = []
    base_source = join_method
    plans = []

    if mode == "fast":
        plans.extend([
            ("fast", base_source),
            ("audiofix", base_source),
            ("reencode", base_source),
        ])
    elif mode == "audiofix":
        plans.extend([
            ("audiofix", base_source),
            ("reencode", base_source),
        ])
    else:
        plans.extend([
            ("reencode", base_source),
        ])

    if base_source != "binary":
        if mode == "fast":
            plans.extend([
                ("audiofix", "binary"),
                ("reencode", "binary"),
            ])
        else:
            plans.append(("reencode", "binary"))
    else:
        if mode == "fast":
            plans.extend([
                ("audiofix", "concat"),
                ("reencode", "concat"),
            ])
        elif mode == "audiofix":
            plans.append(("reencode", "concat"))
        else:
            plans.append(("reencode", "concat"))

    last_reason = None

    for plan_mode, source_kind in plans:
        key = (plan_mode, source_kind)
        if key in tried:
            continue
        tried.append(key)

        if source_kind == "binary":
            source_path = ensure_binary_source_if_needed(ts_files, sources)
        else:
            source_path = sources[source_kind]

        if output_path.exists():
            output_path.unlink()

        print(f"plan_mode={plan_mode} source_kind={source_kind}")
        run_plan(plan_mode, source_kind, source_path, output_path, fps, encoder, preset, quality, delay_ms)

        if skip_validate:
            return plan_mode, source_kind

        ok, reason = validate_media(output_path)
        print(f"validate={ok} reason={reason}")
        if ok:
            return plan_mode, source_kind
        last_reason = reason

    raise RuntimeError(f"输出校验失败: {last_reason}")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("input")
    parser.add_argument("output")
    parser.add_argument("--mode", choices=["auto", "fast", "audiofix", "reencode"], default="auto")
    parser.add_argument("--join-method", choices=["auto", "concat", "binary"], default="auto")
    parser.add_argument("--analyze", choices=["full", "sample", "off"], default="sample")
    parser.add_argument("--workers", type=int, default=8)
    parser.add_argument("--audio-delay-ms", type=int, default=None)
    parser.add_argument("--fps", type=float, default=None)
    parser.add_argument("--encoder", choices=["libx264", "h264_nvenc"], default="libx264")
    parser.add_argument("--quality", type=int, default=20)
    parser.add_argument("--preset", default="fast")
    parser.add_argument("--quick", action="store_true")
    parser.add_argument("--skip-validate", action="store_true")
    parser.add_argument("--report-dir", default=None)
    parser.add_argument("--keep-temp", action="store_true")
    args = parser.parse_args()

    ensure_tools()

    input_path = Path(args.input).expanduser().resolve()
    output_path = Path(args.output).expanduser().resolve()
    output_path.parent.mkdir(parents=True, exist_ok=True)

    if not input_path.exists():
        raise FileNotFoundError(f"输入不存在: {input_path}")

    temp_obj = tempfile.TemporaryDirectory(prefix="ts_sync_opt_")
    workdir = Path(temp_obj.name)

    try:
        media_root = extract_if_zip(input_path, workdir)
        ts_files = find_ts_files(media_root)

        analysis = analyze_ts_files(ts_files, workers=args.workers, analyze_mode=args.analyze)
        summary = analysis["summary"]

        report_dir = Path(args.report_dir).expanduser().resolve() if args.report_dir else (output_path.parent / "ts_report")
        csv_path, json_path = write_report(report_dir, analysis)

        chosen_mode = choose_mode(args.mode, summary, quick=args.quick)
        chosen_join_method = choose_join_method(args.join_method, quick=args.quick)
        delay_ms = auto_audio_delay(args.audio_delay_ms, summary)
        fps = args.fps or summary["fps_guess"] or 25.0

        sources = build_sources(ts_files, workdir)

        actual_mode, actual_source = process_with_fallback(
            ts_files=ts_files,
            sources=sources,
            output_path=output_path,
            mode=chosen_mode,
            join_method=chosen_join_method,
            fps=fps,
            encoder=args.encoder,
            preset=args.preset,
            quality=args.quality,
            delay_ms=delay_ms,
            skip_validate=args.skip_validate,
        )

        print(f"output={output_path}")
        print(f"size={human_size(output_path.stat().st_size)}")
        print(f"mode={actual_mode}")
        print(f"source={actual_source}")
        print(f"audio_delay_ms={delay_ms}")
        print(f"fps={fps}")
        print(f"report_csv={csv_path}")
        print(f"report_json={json_path}")

        if args.keep_temp:
            print(f"temp={workdir}")
            temp_obj.cleanup = lambda: None

    finally:
        if not args.keep_temp:
            temp_obj.cleanup()


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(f"error: {e}", file=sys.stderr)
        sys.exit(1)

十八、结语

到这里,这篇文章就完整闭环了:

  • 前面讲清楚了问题根因
  • 中间讲清楚了修复思路和工程设计
  • 文末给出了完整代码与使用方式

这样读者不仅知道"怎么做",还知道"为什么要这么做"。

相关推荐
好家伙VCC3 小时前
**CQRS模式实战:用Go语言构建高并发读写分离架构**在现代分布式系统中,随着业务复杂度的提升和用户量的增长,传统的单数据库模型逐
java·数据库·python·架构·golang
fy121633 小时前
Java进阶——IO 流
java·开发语言·python
flyfox3 小时前
OpenClaw(龙虾) Skills 实战开发指南
人工智能·python·源码
深藏功yu名3 小时前
Day27:LangGraph 实战落地|Tool_RAG + 并行子图 + 持久化部署,打造工业级 AI Agent
人工智能·python·ai·pycharm·rag·langgrap
reasonsummer4 小时前
【办公类-142-04】20260330插班生word转长表EXCLE(4)新表重制
python·word
曲幽4 小时前
FastAPI + Celery 实战:异步任务里调用 Redis 和数据库的全解析,及生产级组织方案
redis·python·fastapi·web·async·celery·task·queue
程序员三藏4 小时前
软件测试:白盒测试详解
自动化测试·软件测试·python·测试工具·职场和发展·单元测试·测试用例