6093 字
30 分钟
用 Python + FFmpeg 写一个延时摄影合成脚本

用 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 播放器都能放
R34K 导出速度可接受700 张不能等到天荒地老
R4文件名可辨识隔几天翻回去还能认出是哪次拍摄
R5运行时有反馈知道跑到哪了、还要多久,而不是干等
R6参数可调帧率、分辨率、画质、速度能按需开关

其中 R2 是硬需求。视频做出来自己看是一回事,发给别人、拷到电视上放是另一回事——如果对方打不开,这个片子等于没做。

R5 看起来是”锦上添花”,但 700 张的渲染动辄几分钟,没有进度反馈的等待体验极差,实际上是刚需。


二、思路#

2.1 为什么不直接用剪辑软件#

Premiere / DaVinci 都能做图片序列,但:

  • 几百张 3600 万像素图导入时间轴,内存和显存压力巨大;
  • 参数调整靠鼠标点,批次处理不现实;
  • 最关键的——它们不解决”输出能不能播”的问题,导出参数照样得自己懂。

所以正确的分工是:Python 负责编排(找图、拼清单、调参数、并行、报进度),FFmpeg 负责真正的解码与编码。FFmpeg 才是这件事的核心工具,脚本只是把它包成一个顺手的命令行工具。

2.2 为什么用 concat demuxer#

FFmpeg 合成图片序列有两条路:

方案 A:图片序列模式

Terminal window
ffmpeg -framerate 25 -i DSC_%04d.jpg ...

要求文件名必须从 ​1​ 开始、连续编号、固定位数。而相机的实际文件名是 ​810_2161.JPG​ 这种,中间还可能缺号,不满足条件。

方案 B:concat demuxer

Terminal window
ffmpeg -f concat -safe 0 -i list.txt ...

list.txt 里逐个列出文件,还能给每张图单独指定显示时长:

file '810_2161.JPG'
duration 0.040000
file '810_2162.JPG'
duration 0.040000

对文件名没有任何要求,任意命名、任意顺序都能用。所以选 B。

2.3 整体架构#

扫描文件夹 (*.jpg / *.JPG)
│
├─ 排序、去重
│
├─ 探测源图尺寸 (ffprobe) ──→ 算出精确输出尺寸
│
├─ 生成 concat 清单(含 duration)
│
├─ 单进程 ────→ ffmpeg ──────────────→ 输出 mp4
│
└─ 多进程 ────→ 切片 → N 个 ffmpeg 并行 → ffmpeg -c copy 拼接 → 输出 mp4
│
└─ 读 -progress 输出 → 汇总进度条

关键决策有两个:

  1. 默认不按原图分辨率输出。 R2 的成败直接由分辨率决定,所以默认收到一个兼容性安全的值,想要大尺寸显式指定。
  2. 速度靠多进程而不是多线程。 原因见原理 3.5,这个结论是实测出来的,和直觉相反。

2.4 技术选型#

环节选择理由
语言Python 3编排逻辑清晰,标准库够用
合成FFmpeg + concat demuxer见 2.2
编码器libx264兼容性最好;硬件编码实测无价值(见 5.6)
尺寸探测ffprobe随 FFmpeg 一起装,无需额外依赖
进度-progress pipe:1FFmpeg 官方的机器可读进度
并行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(最大帧尺寸,单位宏块) :

LevelMaxFS(宏块)换算像素上限
5.022,0805,652,480
5.136,8649,437,184
6.0 / 6.1 / 6.2139,26435,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 落在文件末尾。

后果有两个:

  1. 流式播放失败。 播放器必须拿到 ​moov 才知道怎么解码。放在末尾意味着必须把整个文件读完才能开始——网络盘、U 盘、手机上”边下边播”就会表现为打不开。
  2. 中断即报废。 如果渲染中途被 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=tv

3.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"

这么做有两个好处:

  1. 文件名里的分辨率一定和实际画面一致(见 4.6);
  2. 顺手把 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 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) # 防止线程暴涨抢核
chunk = (len(img_list) + jobs - 1) // jobs
chunks = [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 刷日志,既刷屏又程序化解析困难。正确做法是用官方的机器可读进度:

Terminal window
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×49126.2手机/电视打不开
手动缩过的那个1920×12825.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 veryfast3.66s
-preset ultrafast3.36s
medium​ + ​-threads 124.72s

​-threads 12​​ 和默认毫无差别。 于是把流程拆开,用 ​-benchmark 单独测每个阶段:

阶段耗时utime / rtime
只解码2.72s2.703 / 2.724
解码 + 缩放 4K2.89s—
解码 + 缩放 1080p2.88s—
完整 4K 输出(medium)4.67s—

结论清晰:

  1. 解码独占 2.72s,缩放+编码总共只有 0.17s;
  2. 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耗时
默认 bicubic3.44s
bilinear3.39s
fast_bilinear3.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
38403840×25626.0
37603760×25086.0
32003200×21365.1
25602560×17085.0
19201920×12825.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 用法#

Terminal window
# 默认: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.mp4

6.2 参数表#

参数说明默认
-d照片文件夹必填
-f帧率25
-s缩放宽度上限,​0 = 原图1920(​-4k 为 3200)
-px264 presetveryfast(​-4k 为 ultrafast)
-j并行进程数,​0 = 自动0
-4k4K 优化模式关
-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,按需放宽
2moov 在文件末尾 → 网络播放失败、中断即报废-movflags +faststart
3-pix_fmt 改不了 color rangescale=...:out_range=tv
43:2 素材做 4K 撞破 Level 5.1用 3200 宽,而非 3840
5-threads 救不了图片序列(解码单线程)多进程分片 + ​-c copy 拼接
6分辨率几乎不影响速度,瓶颈在解码源图别在编码参数上浪费精力
7Windows GBK 控制台输出 emoji 崩溃stdout.reconfigure(encoding="utf-8")
8concat demuxer 对反斜杠/中文路径敏感相对路径 + ​cwd

附录:完整代码#

import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from datetime import datetime
from 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()
用 Python + FFmpeg 写一个延时摄影合成脚本
https://tangkai.me/posts/用-python--ffmpeg-写一个延时摄影合成脚本/
作者
TangKai
发布于
2026-09-26
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取