用 Python + FFmpeg 写一个延时摄影合成脚本
环境:Windows 10 / FFmpeg 9.0.1 (gyan.dev full build) / Python 3.12
硬件:i7-10710U(6 核 12 线程)/ 16 GB / MX350 + Intel UHD
素材:尼康 D810,7360×4912 JPG(3600 万像素)
一、需求
1.1 场景
拍延时摄影的人都会遇到同一个问题:拍的时候很爽,合的时候很烦。
一次日出就是 700 多张 7360×4912 的 JPG,累计几个小时。拍完只是第一步,真正折磨人的是后期——总不能每次都在 Premiere 里拖几百张图,或者手敲 ffmpeg 命令。
1.2 需求清单
把脑子里模糊的想法整理成可以验收的条目:
| 编号 | 需求 | 验收标准 |
|---|---|---|
| R1 | 一条命令批量合成 | 给个文件夹就能出片,不用手工列文件 |
| R2 | 输出要到处都能播 | 手机、电视、Windows 自带播放器、PC 播放器都能放 |
| R3 | 4K 导出速度可接受 | 700 张不能等到天荒地老 |
| R4 | 文件名可辨识 | 隔几天翻回去还能认出是哪次拍摄 |
| R5 | 运行时有反馈 | 知道跑到哪了、还要多久,而不是干等 |
| R6 | 参数可调 | 帧率、分辨率、画质、速度能按需开关 |
其中 R2 是硬需求。视频做出来自己看是一回事,发给别人、拷到电视上放是另一回事——如果对方打不开,这个片子等于没做。
R5 看起来是”锦上添花”,但 700 张的渲染动辄几分钟,没有进度反馈的等待体验极差,实际上是刚需。
二、思路
2.1 为什么不直接用剪辑软件
Premiere / DaVinci 都能做图片序列,但:
- 几百张 3600 万像素图导入时间轴,内存和显存压力巨大;
- 参数调整靠鼠标点,批次处理不现实;
- 最关键的——它们不解决”输出能不能播”的问题,导出参数照样得自己懂。
所以正确的分工是:Python 负责编排(找图、拼清单、调参数、并行、报进度),FFmpeg 负责真正的解码与编码。FFmpeg 才是这件事的核心工具,脚本只是把它包成一个顺手的命令行工具。
2.2 为什么用 concat demuxer
FFmpeg 合成图片序列有两条路:
方案 A:图片序列模式
ffmpeg -framerate 25 -i DSC_%04d.jpg ...要求文件名必须从 1 开始、连续编号、固定位数。而相机的实际文件名是 810_2161.JPG 这种,中间还可能缺号,不满足条件。
方案 B:concat demuxer
ffmpeg -f concat -safe 0 -i list.txt ...list.txt 里逐个列出文件,还能给每张图单独指定显示时长:
file '810_2161.JPG'duration 0.040000file '810_2162.JPG'duration 0.040000对文件名没有任何要求,任意命名、任意顺序都能用。所以选 B。
2.3 整体架构
扫描文件夹 (*.jpg / *.JPG) │ ├─ 排序、去重 │ ├─ 探测源图尺寸 (ffprobe) ──→ 算出精确输出尺寸 │ ├─ 生成 concat 清单(含 duration) │ ├─ 单进程 ────→ ffmpeg ──────────────→ 输出 mp4 │ └─ 多进程 ────→ 切片 → N 个 ffmpeg 并行 → ffmpeg -c copy 拼接 → 输出 mp4 │ └─ 读 -progress 输出 → 汇总进度条关键决策有两个:
- 默认不按原图分辨率输出。 R2 的成败直接由分辨率决定,所以默认收到一个兼容性安全的值,想要大尺寸显式指定。
- 速度靠多进程而不是多线程。 原因见原理 3.5,这个结论是实测出来的,和直觉相反。
2.4 技术选型
| 环节 | 选择 | 理由 |
|---|---|---|
| 语言 | Python 3 | 编排逻辑清晰,标准库够用 |
| 合成 | FFmpeg + concat demuxer | 见 2.2 |
| 编码器 | libx264 | 兼容性最好;硬件编码实测无价值(见 5.6) |
| 尺寸探测 | ffprobe | 随 FFmpeg 一起装,无需额外依赖 |
| 进度 | -progress pipe:1 | FFmpeg 官方的机器可读进度 |
| 并行 | subprocess + 线程读管道 | 单进程解码是瓶颈,只能靠多进程(见 3.5) |
三、原理
要满足 R2 和 R3,必须先搞清楚四件事:这个视频格式为什么会在某些设备上打不开、为什么网络播放会失败、为什么颜色会不对、以及时间到底花在哪。
3.1 concat demuxer 是怎么把图片变成视频的
图片本身是”静止帧”,没有时间概念。concat demuxer 通过 duration 指令给每张图赋予持续时长,FFmpeg 再按这个时长推进 PTS(显示时间戳),于是静止帧就变成了时间轴上连续的画面。
duration = 1.0 / fps # 25fps → 每张 0.04 秒所以帧率并不是直接写进编码器的,而是通过 duration 间接决定的。这也解释了为什么不能同时用 -framerate——那会和 duration 打架。
3.2 H.264 Level 与宏块上限 —— 决定”能不能播”
这是全文最重要的原理,直接决定 R2。
H.264 不是”分辨率越高 Level 越高”这么简单,它按宏块(Macroblock) 算。一个宏块 16×16 像素,一帧的宏块数是:
宏块数 = ceil(宽/16) × ceil(高/16)每个 Level 规定了 MaxFS(最大帧尺寸,单位宏块) :
| Level | MaxFS(宏块) | 换算像素上限 |
|---|---|---|
| 5.0 | 22,080 | 5,652,480 |
| 5.1 | 36,864 | 9,437,184 |
| 6.0 / 6.1 / 6.2 | 139,264 | 35,651,584 |
拿实际数字算一遍就明白了:
7360×4912 → ceil(7360/16)=460, ceil(4912/16)=307 → 460 × 307 = 141,220 宏块 → 超过 6.0 的 139,264,只能标 Level 6.2
3840×2562 → 240 × 161 = 38,640 宏块 → 超过 5.1 的 36,864,落到 Level 6.0
3200×2136 → 200 × 134 = 26,800 宏块 → 在 5.1 的 36,864 之内,Level 5.1 ✓为什么这决定”能不能播”? 因为设备的 H.264 硬解能力普遍只做到 Level 5.1(约 4K)。超过这个 Level,解码器会直接拒绝——不是”放得卡”,是根本不解码。
而 PC 上的 VLC / PotPlayer 走软件解码,任何 Level 都能处理。
这就埋下了整个项目最大的坑:同一批文件,PC 上正常,手机上打不开。播放环境不同,结论不同,极容易被误判成”文件随机损坏”。
3.3 moov 原子与 faststart —— 决定”能不能边下边播”
MP4 是”盒子结构”(box / atom)。其中两个最关键:
-
mdat:真正的音视频数据 -
moov:元数据(时长、轨道、编码参数、每一帧的位置索引)
FFmpeg 默认边编码边写 mdat ,最后才回填 moov。于是 moov 落在文件末尾。
后果有两个:
- 流式播放失败。 播放器必须拿到
moov才知道怎么解码。放在末尾意味着必须把整个文件读完才能开始——网络盘、U 盘、手机上”边下边播”就会表现为打不开。 - 中断即报废。 如果渲染中途被 Ctrl+C,
moov 根本没机会写入,得到一个结构不完整的 mp4,彻底不可播。
-movflags +faststart 让 FFmpeg 编码完后再做一次”搬移”,把 moov 挪到文件开头。
3.4 color range —— 决定”颜色对不对”
数字视频的 YUV 取值有两种约定:
- limited range(tv) :Y 取值 16~235,标准的广播/视频范围
- full range(pc) :Y 取值 0~255,JPEG 家族用的范围
JPEG 是 full range,所以相机产出的 JPG 是 yuvj422p(j 就是 full range 的标记)。
坑在于: -pix_fmt yuv420p 只转换”像素格式”,不会改变 range 标记。
也就是说,写了 -pix_fmt yuv420p,输出仍然是 full range——在容器里被标成 yuvj420p。部分播放器拿到这种标记会渲染错误(偏色、发灰,个别硬解花屏)。
要真正完成 range 转换,必须让 swscale 参与,在 scale 滤镜上显式指定:
scale=...:in_range=auto:out_range=tv3.5 编解码开销的分布 —— 决定”快不快”
要满足 R3,得先知道时间花在哪。一次完整的图片序列编码包含三个阶段:
JPEG 解码(7360×4912) → swscale 缩放 → libx264 编码其中:
- libx264 编码:默认就自动多线程,线程数约为 1.5× 物理核心数。
- swscale 缩放:单滤镜内基本是串行的。
- JPEG 解码:这是关键。FFmpeg 的 MJPEG 解码器实测是单线程的——
utime ≈ rtime 说明它只占用约 1 个核,-threads对它完全无效。
结论:图片序列的瓶颈是单线程的解码器,而不是编码器。 所以”让 ffmpeg 多线程”这条路走不通,唯一的出路是把图片列表切片,同时跑多个 ffmpeg 进程,把各自解码好的分片再用 -c copy 无损拼接起来。
四、实现
4.1 骨架
img_list = sorted(set(img_dir.glob("*.jpg")) | set(img_dir.glob("*.JPG")))duration = 1.0 / args.fps
with open(list_path, "w", encoding="utf-8") as f: for p in img_list: f.write(f"file '{os.path.relpath(p, img_dir)}'\n") f.write(f"duration {duration:.6f}\n")两个细节:
- 用
relpath 写相对路径,清单文件放在图片目录里、cwd 也设成图片目录。这样清单里不出现中文路径和反斜杠,避开 concat demuxer 的转义坑。 set() 去重:Windows 下 *.jpg 的 glob 是大小写不敏感的,*.jpg 和 *.JPG会匹配到同一批文件,用 set 消掉。
4.2 输出尺寸与滤镜
不依赖 ffmpeg 的 -2 自适应,而是自己用 ffprobe 读源尺寸、自己算:
def probe_size(path): r = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v", "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)], capture_output=True, text=True, check=True) w, h = r.stdout.strip().split(",")[:2] return int(w), int(h)
out_w = even(min(target_w, sw)) # 不放大out_h = even(round(sh * out_w / sw)) # 按原比例,取偶数vf = f"scale={out_w}:{out_h}:in_range=auto:out_range=tv,format=yuv420p"这么做有两个好处:
- 文件名里的分辨率一定和实际画面一致(见 4.6);
- 顺手把 range 转换做掉了(原理 3.4)。
even() 保证宽高是偶数——libx264 的 yuv420p 需要偶数字面尺寸,否则直接报错。
4.3 兼容性三件套
针对原理 3.2 / 3.3 / 3.4 的三条对策,落在输出参数上:
cmd += [ "-c:v", "libx264", "-preset", preset, "-crf", str(args.crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart", # 对策 3.3:moov 前置]加上 4.2 的 scale 滤镜(对策 3.4)和默认 1920 的宽度上限(对策 3.2),R2 才算真正满足。
4.4 多进程分片
对应原理 3.5。把图片按顺序切片,每片交给一个独立的 ffmpeg 进程:
cpus = os.cpu_count() or 2jobs = args.jobs if args.jobs > 0 else max(1, cpus // 2) # 默认按物理核心数估jobs = max(1, min(jobs, len(img_list)))enc_threads = max(1, cpus // jobs) # 防止线程暴涨抢核
chunk = (len(img_list) + jobs - 1) // jobschunks = [img_list[i:i + chunk] for i in range(0, len(img_list), chunk)]每个分片单独渲染成临时 mp4(不加 faststart,省掉那次搬移),最后拼接:
cmd = ["ffmpeg", "-f", "concat", "-safe", "0", "-i", segs_txt, "-c", "copy", "-movflags", "+faststart", "-y", out_path]-c copy 是流拷贝,不解码不重编码,所以拼接几乎不花时间。
两个关键约束:
- 分片必须按原顺序切、按原顺序拼,否则画面顺序错乱;
- 所有分片编码参数必须完全一致,否则
-c copy拼出来的东西时间基不统一。因为用的是同一套参数,天然满足。
cpus // 2 是因为 os.cpu_count() 返回的是逻辑核心数,而解码本身吃不满超线程;enc_threads 则是反过来控制每个子进程别把线程开满,避免 N 个进程互相抢核。
4.5 进度条
FFmpeg 默认往 stderr 刷日志,既刷屏又程序化解析困难。正确做法是用官方的机器可读进度:
ffmpeg -hide_banner -loglevel error -nostats -progress pipe:1 ...-progress pipe:1 会每 0.5 秒往 stdout 打一行 key=value,我们只关心 frame=N。
def reader(i, proc): for line in proc.stdout: if line.startswith("frame="): frames[i] = int(line.strip().split("=", 1)[1]) # 各进程独立累计 with lock: draw_progress(sum(frames), total, start) # 汇总成一条总进度多进程时,为每个 ffmpeg 起一个读线程,各自维护自己的 frame,加锁汇总。百分比和 ETA:
百分比 = 已完成帧数 / 总帧数剩余 = 已用时间 × (总数 - 已完成) / 已完成渲染时把 stderr 重定向到临时文件,只在失败时才打印,成功路径下一条日志都看不到。
4.6 自动命名
延时摄影没有官方命名标准,但视频/影视交付领域有个通行做法:ISO 日期打头 + 统一分隔符,好处是文件名按字典序排 = 按时间排,且跨语言无歧义(不会纠结 09-10 是 9 月 10 日还是 10 月 9 日)。
20260924_timelapse_3200x2136_h264.mp4└─日期─┘ └──类型──┘ └─分辨率┘ └编码器┘- 分辨率写真实像素而不是 “4K”。因为
-4k模式输出的是 3200 宽(原理 3.2 的妥协),标成 4K 就是错的。写实际尺寸最诚实,这也是 4.2 里坚持自己算尺寸的原因。 - 日期从文件夹名解析(我的文件夹都是
20260924-描述这种),解析不到就用第一张图的修改时间兜底。
def parse_date(text): m = re.match(r"(\d{4})[-_.]?(\d{2})[-_.]?(\d{2})", text) return f"{m.group(1)}{m.group(2)}{m.group(3)}" if m else None五、排错
5.1 症状:“有些视频能播,有些播不了”
这是启动整个优化过程的原始问题。把已生成的视频全部 ffprobe 一遍:
| 文件 | 分辨率 | Level | 结果 |
|---|---|---|---|
| 全尺寸输出 | 7360×4912 | 6.2 | 手机/电视打不开 |
| 手动缩过的那个 | 1920×1282 | 5.0 | 到处都能播 |
规律立刻显现:能播的都是低分辨率。对照原理 3.2,元凶是 Level 超限——而且”PC 能播、手机不能播”正是软解 vs 硬解的差异,完美解释了”随机性”的假象。
5.2 定位 moov 问题
Level 之外,又检查了容器结构。不看不知道,所有输出文件的 moov 都在末尾——直接读文件头几千字节,连 ASCII moov 都找不到:
timelapse.mp4 moov@-1 mdat@44 ← moov 不在前 2MB 里对照原理 3.3,这就是”网络播放失败 + 中断即报废”的根因。加上 -movflags +faststart 后:
out.mp4 moov@36 mdat@995 ← moov 跑到文件开头5.3 定位 color range 问题
-pix_fmt 明明写了 yuv420p,输出却是 yuvj420p。对照原理 3.4 才知道 -pix_fmt 管不了 range。改用 scale=...:out_range=tv 之后,ffprobe 确认变为 yuv420p / color_range=tv。
5.4 速度排查:多线程为什么没用
R3 的第一反应是”让 ffmpeg 多线程”,实测结果反直觉。
12 张 7360×4912 → 4K,25fps:
| 配置 | 耗时 |
|---|---|
-preset medium(原默认) | 4.67s |
-preset veryfast | 3.66s |
-preset ultrafast | 3.36s |
medium + -threads 12 | 4.72s |
-threads 12 和默认毫无差别。 于是把流程拆开,用 -benchmark 单独测每个阶段:
| 阶段 | 耗时 | utime / rtime |
|---|---|---|
| 只解码 | 2.72s | 2.703 / 2.724 |
| 解码 + 缩放 4K | 2.89s | — |
| 解码 + 缩放 1080p | 2.88s | — |
| 完整 4K 输出(medium) | 4.67s | — |
结论清晰:
- 解码独占 2.72s,缩放+编码总共只有 0.17s;
utime ≈ rtime 证明 MJPEG 解码是单线程;再试 -threads 6:utime=2.688 / rtime=2.725,纹丝不动。
还有个附带发现:4K(2.99s)和 1080p(2.92s)几乎一样快——因为大头是解码 3600 万像素的源图,跟输出多大无关。
既然单进程卡死,就上多进程(实现 4.4)。实测:
- 6 进程 × 2 张,纯解码:2.72s → 0.894s
- 6 进程 × 2 张,完整编码:2.99s → 1.14s
- 端到端 24 张 4K:
-j 1 6.82s → -j 6 3.35s,约 2 倍
5.5 编码器参数(换 preset)和调整 scaler
既然缩放只占 0.17s,顺手验证了一下缩小它的收益:
| scaler flags | 耗时 |
|---|---|
| 默认 bicubic | 3.44s |
| bilinear | 3.39s |
| fast_bilinear | 3.47s |
全在噪声范围内,没必要动。真正有效的只有换 preset——ultrafast 比 medium 快约 28%。
5.6 走不通的路:NVENC / QSV / GPU 解码
| 方案 | 结果 |
|---|---|
h264_nvenc(MX350) | ❌ Cannot load cuMemAllocAsync,老驱动 + 该卡 NVENC 支持残缺 |
hevc_qsv | ❌ Error querying encoder params: unsupported |
h264_qsv | ✅ 可用,3.83s —— 但对照 5.4,编码阶段总共才 0.17s,加速编码解决不了瓶颈 |
mjpeg_qsv + scale_qsv(核显解码/缩放) | ❌ Function not implemented,驱动太旧 + 7360 宽度超出 QSV 限制 |
结论:这台机器上硬件加速没有价值。 瓶颈在 CPU 解码,不在编码,老老实实用 libx264。当然换成有正经独显的台式机,结论可能完全不同——所以这些是”实测”而非”定论”。
5.7 4K 的等级陷阱
做完上面所有优化,我想加个”一键 4K”,结果又踩了一次原理 3.2 的坑。
因为照片是 3:2,宽度 3840 对应高度 2562:
240 × 161 = 38,640 宏块 > 5.1 的上限 36,864“标准 4K 宽度”3840 在 3:2 比例下刚好顶破 Level 5.1,被判成 6.0,兼容性问题原地复活。实测各宽度:
| 输出宽度 | 实际尺寸 | Level |
|---|---|---|
| 3840 | 3840×2562 | 6.0 |
| 3760 | 3760×2508 | 6.0 |
| 3200 | 3200×2136 | 5.1 |
| 2560 | 2560×1708 | 5.0 |
| 1920 | 1920×1282 | 5.0 |
最终取舍:-4k 模式用 3200 宽,稳在 Level 5.1(3200×2136 观感已经接近 4K),再叠上 ultrafast 和自动并行。真想要 4K 就 -4k -s 3840,自行接受 Level 6.0。
注意:16:9 素材的 3840×2160 是 Level 5.1,没这个问题。只有 3:2 / 4:3 这类”高”比例才会撞上限。
5.8 顺路踩的小坑
-
Windows 控制台是 GBK,脚本里
print("✅ ...") 直接抛 UnicodeEncodeError崩溃。一行解决:if hasattr(sys.stdout, "reconfigure"):sys.stdout.reconfigure(encoding="utf-8", errors="replace") -
concat demuxer 对反斜杠敏感,中文绝对路径也很容易出问题。用相对路径 +
cwd规避。
六、成品
6.1 用法
# 默认:1920 宽,兼容性优先python timelapse.py -d "20260924-水库日出\JPG\TIME"
# 一键 4K:3200 宽(Level 5.1) + ultrafast + 自动并行python timelapse.py -d "..." -4k
# 真 4K(Level 6.0,自行权衡)python timelapse.py -d "..." -4k -s 3840
# 手动控制python timelapse.py -d "..." -f 25 -s 1920 -p veryfast -j 6 -o my.mp46.2 参数表
| 参数 | 说明 | 默认 |
|---|---|---|
-d | 照片文件夹 | 必填 |
-f | 帧率 | 25 |
-s | 缩放宽度上限,0 = 原图 | 1920(-4k 为 3200) |
-p | x264 preset | veryfast(-4k 为 ultrafast) |
-j | 并行进程数,0 = 自动 | 0 |
-4k | 4K 优化模式 | 关 |
-o | 输出文件名 | 自动命名 |
--crf | 画质,越大越低 | 23 |
关于帧率(R6 的一个具体问题):不能拍脑袋定,要先看拍摄间隔。
加速倍数 = 帧率 × 拍摄间隔成片长度 = 张数 ÷ 帧率日出日落这类云和光线的运动,40150 倍最舒服。5s 间隔配 25fps 正好 125 倍,一小时素材出 29 秒成片。24/25/30 都是标准帧率,延时本身运动慢,超过 30 没有意义。素材少就要降帧率拉长(128 张配 25fps 只有 5 秒,得降到 1012fps)。
6.3 实测效果
✅ 一共找到 60 张图片✅ 4K优化模式:宽度 3200(Level 5.1)+ preset ultrafast + 自动并行✅ 输出文件:20260924_timelapse_3200x2136_h264.mp4✅ 并行渲染:6 个分片,每个进程 2 线程(共 12 线程)
开始渲染…[████████░░░░░░░░░░░░░░░░░░░░] 30.0% 已用 0:02 剩余 0:06[██████████████████░░░░░░░░░░] 66.7% 已用 0:03 剩余 0:01[████████████████████████████] 100.0% 已用 0:03 剩余 0:00✅ 分片渲染完成,开始拼接…✅ 渲染完成!✅ 临时文件已清理输出文件稳定为 Level 5.1 / yuv420p(tv) / moov@36,手机、电视、PC 播放器通吃。
星空日出那组 716 张,-4k 大约 1.5 分钟出片。
6.4 踩坑清单
| # | 坑 | 对策 |
|---|---|---|
| 1 | 原图分辨率输出 → Level 6.2,大半设备拒播 | 默认缩到 1920,按需放宽 |
| 2 | moov 在文件末尾 → 网络播放失败、中断即报废 | -movflags +faststart |
| 3 | -pix_fmt 改不了 color range | scale=...:out_range=tv |
| 4 | 3:2 素材做 4K 撞破 Level 5.1 | 用 3200 宽,而非 3840 |
| 5 | -threads 救不了图片序列(解码单线程) | 多进程分片 + -c copy 拼接 |
| 6 | 分辨率几乎不影响速度,瓶颈在解码源图 | 别在编码参数上浪费精力 |
| 7 | Windows GBK 控制台输出 emoji 崩溃 | stdout.reconfigure(encoding="utf-8") |
| 8 | concat demuxer 对反斜杠/中文路径敏感 | 相对路径 + cwd |
附录:完整代码
import argparseimport osimport reimport shutilimport subprocessimport sysimport tempfileimport threadingimport timefrom datetime import datetimefrom pathlib import Path
BAR_WIDTH = 28
def probe_size(path): """用ffprobe读一张图的宽高(只读文件头,很快)""" try: r = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "v", "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)], capture_output=True, text=True, check=True) w, h = r.stdout.strip().split(",")[:2] return int(w), int(h) except Exception: return None
def parse_date(text): """从文件夹名开头解析日期,统一成 YYYYMMDD""" m = re.match(r"(\d{4})[-_.]?(\d{2})[-_.]?(\d{2})", text) if m: return f"{m.group(1)}{m.group(2)}{m.group(3)}" return None
def even(n): return int(n) - int(n) % 2
def build_video_filter(out_w, out_h): # 源JPG是yuvj422p全范围,只写-pix_fmt yuv420p不会改变range, # 所以显式指定偶数尺寸并转成有限范围(tv)的标准yuv420p return f"scale={out_w}:{out_h}:in_range=auto:out_range=tv,format=yuv420p"
def write_concat_list(path, images, img_dir, duration): with open(path, "w", encoding="utf-8") as f: for p in images: f.write(f"file '{os.path.relpath(p, img_dir)}'\n") f.write(f"duration {duration:.6f}\n")
def format_duration(seconds): seconds = max(0, int(seconds)) h, rem = divmod(seconds, 3600) m, s = divmod(rem, 60) return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
def draw_progress(done, total, start): frac = 0.0 if total <= 0 else min(1.0, done / total) filled = int(frac * BAR_WIDTH) bar = "█" * filled + "░" * (BAR_WIDTH - filled) elapsed = time.time() - start eta = format_duration(elapsed * (total - done) / done) if done > 0 else "--:--" line = f"\r[{bar}] {frac * 100:5.1f}% 已用 {format_duration(elapsed)} 剩余 {eta}" sys.stdout.write(line + " " * max(0, 84 - len(line))) sys.stdout.flush()
def run_ffmpeg_batch(cmds, totals, cwd): """并行跑多个ffmpeg,汇总成一条进度条;返回 [(returncode, stderr文本), ...]""" total = sum(totals) frames = [0] * len(cmds) lock = threading.Lock() start = time.time() logs = [] procs = []
for cmd in cmds: fd, path = tempfile.mkstemp(prefix="tl_log_", suffix=".txt") fh = os.fdopen(fd, "wb") logs.append((fh, path)) procs.append(subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE, stderr=fh, text=True, bufsize=1))
def reader(i, proc): for line in proc.stdout: if line.startswith("frame="): try: frames[i] = int(line.strip().split("=", 1)[1]) except ValueError: continue with lock: draw_progress(sum(frames), total, start)
threads = [threading.Thread(target=reader, args=(i, p), daemon=True) for i, p in enumerate(procs)] for t in threads: t.start() codes = [p.wait() for p in procs] for t in threads: t.join() for fh, _ in logs: fh.close() draw_progress(total, total, start) sys.stdout.write("\n") sys.stdout.flush()
results = [] for code, (_, path) in zip(codes, logs): try: with open(path, "r", encoding="utf-8", errors="replace") as fh: err = fh.read().strip() except OSError: err = "" try: os.unlink(path) except OSError: pass results.append((code, err)) return results
def main(): # Windows默认控制台是GBK,无法输出✅等表情,会直接崩溃,这里强制UTF-8 if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description="用ffmpeg concat模式合成延时摄影(图片清单)") parser.add_argument("--dir", "-d", required=True, help="照片所在文件夹路径") parser.add_argument("--fps", "-f", type=float, default=25, help="输出视频帧率,默认25") parser.add_argument("--output", "-o", default=None, help="输出mp4文件名,默认自动命名:yyyymmdd_timelapse_宽x高_编码器.mp4") parser.add_argument("--scale", "-s", default=None, help="缩放宽度上限,默认1920(-4k模式为3200);填0保留原图尺寸") parser.add_argument("--crf", type=int, default=23, help="H.264 CRF画质,默认23,越大画质越低") parser.add_argument("--preset", "-p", default=None, help="x264预设:ultrafast最快 / veryfast / medium画质更好但更慢(默认veryfast,-4k模式为ultrafast)") parser.add_argument("--jobs", "-j", type=int, default=0, help="并行进程数,0=自动(约等于CPU物理核心数),1=单进程") parser.add_argument("-4k", "--4k", dest="four_k", action="store_true", help="4K优化模式:3200宽(Level 5.1) + ultrafast + 自动并行") args = parser.parse_args()
# -4k 给出一组优化默认值;单独再用 -s / -p 仍可覆盖 scale = args.scale if args.scale is not None else ("3200" if args.four_k else "1920") preset = args.preset if args.preset is not None else ("ultrafast" if args.four_k else "veryfast")
img_dir = Path(args.dir) if not img_dir.exists(): print(f"错误:目录不存在 {img_dir}") return
# 搜索jpg,大小写兼容,不会重复 img_list = [] for ext in ("*.jpg", "*.JPG"): img_list.extend(img_dir.glob(ext)) img_list = sorted(list(set(img_list))) #去重,排序 if len(img_list) == 0: print("错误:文件夹内没有找到jpg图片!") return print(f"✅ 一共找到 {len(img_list)} 张图片") if args.four_k: print(f"✅ 4K优化模式:宽度 {scale}(Level 5.1)+ preset {preset} + 自动并行")
# 显式算出偶数输出尺寸:这样文件名里的分辨率一定和实际画面一致 scale_arg = str(scale).strip().lower() keep_native = scale_arg in ("0", "", "none", "original") src = probe_size(img_list[0]) if src: sw, sh = src if keep_native: out_w = even(sw) else: try: target_w = int(float(scale_arg)) except ValueError: print(f"错误:--scale 需要是数字或 0,收到 {scale_arg}") return out_w = even(min(target_w, sw)) out_h = even(round(sh * out_w / sw)) vf = build_video_filter(out_w, out_h) res_tag = f"{out_w}x{out_h}" else: print("⚠️ ffprobe读不到源尺寸,改用自适应缩放(文件名分辨率可能不精确)") if keep_native: vf = "scale=trunc(iw/2)*2:trunc(ih/2)*2:in_range=auto:out_range=tv,format=yuv420p" res_tag = "native" else: vf = f"scale='trunc(min({scale_arg},iw)/2)*2':-2:in_range=auto:out_range=tv,format=yuv420p" res_tag = f"{scale_arg}w"
codec_tag = "h264" # 目前固定用 libx264 if args.output: out_path = img_dir / args.output else: date = parse_date(img_dir.name) or datetime.fromtimestamp(img_list[0].stat().st_mtime).strftime("%Y%m%d") out_path = img_dir / f"{date}_timelapse_{res_tag}_{codec_tag}.mp4" print(f"✅ 输出文件:{out_path.name}")
duration = 1.0 / args.fps
# MJPEG解码是单线程的(实测 -threads 无效),所以提速只能靠"多个ffmpeg进程分片并行" cpus = os.cpu_count() or 2 jobs = args.jobs if args.jobs > 0 else max(1, cpus // 2) jobs = max(1, min(jobs, len(img_list))) enc_threads = max(1, cpus // jobs) enc = ["-c:v", "libx264", "-preset", preset, "-crf", str(args.crf), "-threads", str(enc_threads)]
def base_cmd(in_list): # -loglevel error 关掉日志,-progress pipe:1 输出机器可读进度 return ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats", "-progress", "pipe:1", "-f", "concat", "-safe", "0", "-i", str(in_list), "-vf", vf] + enc + ["-r", str(args.fps)]
# 单进程:直接输出 if jobs == 1: list_path = img_dir / "ffmpeg_list.txt" write_concat_list(list_path, img_list, img_dir, duration) cmd = base_cmd(list_path) + ["-pix_fmt", "yuv420p", "-movflags", "+faststart", "-y", str(out_path)] print("\n开始渲染…") code, err = run_ffmpeg_batch([cmd], [len(img_list)], str(img_dir))[0] list_path.unlink(missing_ok=True) if code == 0: print("✅ 渲染完成!") else: print(f"❌ ffmpeg执行失败,返回码:{code}") if err: print(err) print("✅ 临时清单文件已清理") return
# 多进程:按顺序切成 jobs 个分片并行渲染,再用 -c copy 无损拼接 chunk = (len(img_list) + jobs - 1) // jobs chunks = [img_list[i:i + chunk] for i in range(0, len(img_list), chunk)] print(f"✅ 并行渲染:{len(chunks)} 个分片,每个进程 {enc_threads} 线程(共 {cpus} 线程)") print("\n开始渲染…")
tmpdir = Path(tempfile.mkdtemp(prefix="timelapse_")) seg_lists = [] seg_files = [] try: cmds = [] for i, part in enumerate(chunks): lp = img_dir / f"ffmpeg_list_{i:03d}.txt" write_concat_list(lp, part, img_dir, duration) seg_lists.append(lp) seg = tmpdir / f"seg_{i:03d}.mp4" seg_files.append(seg) cmds.append(base_cmd(lp) + ["-pix_fmt", "yuv420p", "-y", str(seg)])
results = run_ffmpeg_batch(cmds, [len(c) for c in chunks], str(img_dir)) failed = [(code, err) for code, err in results if code != 0] if failed: print("❌ 有分片渲染失败,已中止") for code, err in failed: print(f"返回码 {code}: {err}") return
print("✅ 分片渲染完成,开始拼接…") seg_list = tmpdir / "segs.txt" with open(seg_list, "w", encoding="utf-8") as f: for s in seg_files: f.write(f"file '{s.as_posix()}'\n")
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats", "-f", "concat", "-safe", "0", "-i", str(seg_list), "-c", "copy", "-movflags", "+faststart", "-y", str(out_path)] ret = subprocess.run(cmd, cwd=str(img_dir), capture_output=True, text=True) if ret.returncode == 0: print("✅ 渲染完成!") else: print(f"❌ 拼接失败,返回码:{ret.returncode}") if ret.stderr.strip(): print(ret.stderr.strip()) finally: for lp in seg_lists: lp.unlink(missing_ok=True) shutil.rmtree(tmpdir, ignore_errors=True) print("✅ 临时文件已清理")
if __name__ == "__main__": main()分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 29 天
部分内容可能已过时