一、问题现象:为什么一堆 .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,
):
这层逻辑的意义在于:
- 先按当前模式尝试
- 如果失败,升级到更稳策略
- 如果还失败,切换输入组织方式
- 一直到得到可通过校验的结果
这种设计比"写死一条最优命令"更符合真实工程。
因为你无法保证所有 .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)
十八、结语
到这里,这篇文章就完整闭环了:
- 前面讲清楚了问题根因
- 中间讲清楚了修复思路和工程设计
- 文末给出了完整代码与使用方式
这样读者不仅知道"怎么做",还知道"为什么要这么做"。