
简介这是一份用于剪映视频自动化剪辑的 Python 桌面工具源码面向熟悉 Python 的视频创作者、自媒体运营者和剪辑爱好者。工具可自动识别视频关键帧按设定条件挑选最佳帧实现上下/左右运镜、放大、随机动画、删除关键帧、图文音轨处理等批量操作让重复性剪辑工作更高效。压缩包共 30 个文件约 37.56MB包含 9 个 py 源码文件、8 个 pyc 编译模块、4 个 XML 配置、2 个 JSON 参数文件以及 UI 界面文件、图标、依赖说明和可直接运行的 exe兼顾二次开发与开箱即用。目前已有一千余人学习/下载源码目录中同时提供了 GUI 入口与核心算法模块适合用来理解剪映关键帧处理逻辑、定制个人剪辑脚本或作为自动化视频编辑项目的起点。1. 为什么不用手拖关键帧Python 桌面版把剪映草稿变成了可编辑的数据做批量剪辑最磨人的不是剪而是给几十上百段素材逐个打关键帧。这个 Python剪映关键帧桌面版 就是奔着这个痛点去的它直接读取剪映草稿里的关键帧数据在桌面窗口里用表格加时间轴展示支持整体偏移、区间复制、批量替换改完写回草稿剪映重新打开就是新效果。它本质是一个完整的 Python 桌面软件源码适合两类人——频繁做模板化产出的剪辑者以及想研究剪映草稿结构、拿 Python 写剪辑工具链的技术用户。想跑起来只需要本机有 Python 3.8 以上pip 装一个 PyQt5 就能启动不依赖剪映本身开放任何接口。2. 整体架构剪映草稿JSON怎么变成一张可编辑的关键帧表2.1 技术选型PyQt5 标准库JSON解析先说为什么不做成剪映插件而是独立桌面软件。剪映的插件体系能力有限草稿格式也不对外公开与其在软件内部做受限扩展不如在外部把草稿文件直接当数据源。桌面程序读草稿、改草稿、写回草稿完全独立于剪映版本升级功能边界清晰出了问题也不会拖累剪辑软件本体。解析层用 Python 标准库 json 就够了。剪映草稿在磁盘上是一个以 .draft 结尾的目录里面核心数据全部集中在 draft_content.json 一个文件里素材轨、文字轨、音频轨、关键帧都是 JSON 树形结构。dict 天生贴合 JSON 的层级关系不需要 ORM不需要数据库改完再 dumps 回去哪个字段动了哪个片段一目了然。GUI 选 PyQt5 而不是 tkinter这点是我拆这个项目时最认同的决定。关键帧编辑器最核心的交互是拖拽和框选QGraphicsView/QGraphicsScene 这套图元框架把时间轴画布、缩放、平移、橡皮筋框选都封装好了tkinter 的 Canvas 要手写坐标换算、重绘节流、图元拾取开发成本直接翻倍。PyQt5 对 Python 3.8 有预编译 wheelWindows 和 macOS 都是 pip install 直接装不要求用户本地有 C 编译环境。这里有个血泪经验剪映草稿字段每个版本都可能微调解析层一定要和业务逻辑隔离开否则每次剪映升级都要全项目跟着返工。项目里 core 目录不 import 任何 PyQt5所有草稿读取、关键帧解析、批量操作都是纯 Python界面层只是薄薄一层壳。2.2 目录结构core逻辑与ui界面各管一段keyframe_desktop/ ├── main.py # 程序入口加载配置、启动主窗口 ├── config.py # 草稿目录列表、备份开关、界面参数 ├── core/ # 与界面无关的纯逻辑 │ ├── draft_loader.py # 扫描磁盘定位并读取草稿JSON │ ├── keyframe_model.py # Keyframe数据模型与不同版本归一化 │ ├── keyframe_ops.py # 批量偏移/区间复制/参数替换操作 │ └── draft_writer.py # 备份、写回、数值校验 ├── ui/ │ ├── main_window.py # 主窗口三栏布局和菜单 │ ├── timeline_widget.py # QGraphicsView时间轴画布 │ └── keyframe_table.py # QTableView关键帧表格 └── tests/ └── test_draft_roundtrip.py # 读写回环测试验证不丢字段文件职责依赖关系draft_loader.py递归查找草稿、读取JSON文本只依赖标准库keyframe_model.py定义 Keyframe 数据类做字段归一化被 loader、ops、ui 共同引用keyframe_ops.py批量操作全部是纯函数式接口只依赖 modeldraft_writer.py备份、合并、原子写盘只依赖标准库main_window.py组装三栏界面转发用户事件依赖 core 全部模块timeline_widget.py时间轴绘制、拖拽、框选依赖 model这个分层带来的实际好处是自动化测试可以在没有显示器的环境里跑。tests 目录里的回环测试直接调用 core 层函数读一份草稿、改几个关键帧时间点、写回再读回来对比确认除了预期变更外一个字段都没丢。想加新功能比如按音频响度自动生成关键帧也只需要在 core 目录里加一个算法模块UI 不用动。2.3 数据流从磁盘到窗口再写回磁盘整个程序的数据流可以缩成一条链路扫描候选目录找到 draft_content.json读取成 dict把 dict 里的原始关键帧字段归一化成统一的 Keyframe 对象UI 把 Keyframe 对象渲染成时间轴曲线和表格行用户编辑时只改内存里的对象点保存时先备份原文件再把模型序列化回 JSON写临时文件后原子替换。# ui/main_window.py 里打开一个草稿的完整调用链 def on_open_draft(self, draft_name: str): loader DraftLoader(self.config.candidate_dirs) path loader.find_by_name(draft_name) # 在候选目录中精确定位 self.raw_draft loader.load(path) # dict保留原始结构 self.model KeyframeModel.from_draft(self.raw_draft) # 归一化关键帧 self.fragment_list.set_items(self.model.fragments()) # 左栏片段列表 self.timeline.set_model(self.model) # 中间时间轴画布 self.keyframe_table.set_model(self.model) # 右侧参数表格逻辑说明loader.load 返回的是 json.loads 之后的原始 dict这里刻意不立刻解析是为了后续 merge 写回时能拿到完整的原始树。KeyframeModel.from_draft 是静态方法它遍历原始 dict 里的 clips 列表把剪映不同版本的关键帧字段统一成 Keyframe 对象。三个 UI 组件绑定的是同一个 model 实例所以表格里改时间时间轴立刻同步。写回链路同样收敛到三个方法def on_save(self): self.writer.backup(self.content_path) # 先复制一份 .bak tree self.model.to_draft(self.raw_draft) # 模型合并回原始dict self.writer.merge_and_write(tree, self.content_path)这套链路把风险全部集中在最后一步写盘。前面的解析和编辑都是纯内存操作误操作直接关掉重开就恢复原样即使写盘时出了问题还有 .bak 兜底。理解了这个数据流再看后面几章的核心代码会顺很多。3. 核心模块拆解草稿探测、关键帧模型与批量操作3.1 草稿探测怎么定位draft_content.json剪映的草稿在磁盘上是一堆以 .draft 结尾的目录目录名是时间戳或随机字符串draft_content.json 就藏在这些目录里。程序要做的第一件事是从配置的候选目录里把这份文件全部找出来让用户自己选而不是假设磁盘上只有一个草稿。# core/draft_loader.py import json from pathlib import Path class DraftLoader: def __init__(self, candidate_dirs: list[Path]): self.candidate_dirs candidate_dirs def find_drafts(self) - list[Path]: 递归查找所有含 draft_content.json 的草稿目录 hits [] for base in self.candidate_dirs: if not base.exists(): continue content_files list(base.rglob(draft_content.json)) hits.extend(content_files) return hits def load(self, content_path: Path) - dict: 读取草稿JSON返回原始dict不做任何修改 raw_text content_path.read_text(encodingutf-8) return json.loads(raw_text)逻辑说明rglob 是递归扫描能覆盖草稿目录下多层嵌套的情况。find_drafts 返回的是文件路径列表而不是直接加载内容这样界面可以先展示发现 5 个草稿请选择避免一次性读入所有大文件把内存塞满。candidate_dirs 来自 config.py初次运行会让用户手动选择一次剪映草稿所在的根目录然后写进 config.json之后启动自动扫描。参数说明candidate_dirs 最好是剪映草稿目录和用户手动添加的备份目录的并集。如果用户把草稿路径改过只需要在配置文件里加一行目录不必改代码。load 方法返回的 dict 里保留了原始 JSON 的全部键哪怕有些字段解析层不认识写回时候也能原样保留这是后面安全写回的前提。3.2 关键帧模型把剪映字段归一化成可计算的Keyframe剪映不同版本的草稿里关键帧字段的命名和单位都不一样。有的版本叫 keyframes有的版本叫 animations时间字段有的存微秒有的存秒。如果每一处代码都去判断版本项目就散架了。这里用一个统一数据类把差异吞掉后续所有 UI 和批量操作只认这个模型。# core/keyframe_model.py from dataclasses import dataclass dataclass class Keyframe: fragment_id: str # 所属素材片段ID对应草稿里某个clip param: str # 参数名scale / x / y / rotation time: float # 时间轴位置模型内部统一用秒 value: float # 参数值模型内部统一用比率数 interpolation: str linear # linear / easeIn / easeOut / bezier def to_draft_format(self, time_unit: int 1_000_000) - dict: 写回时把秒换算回剪映的时间单位默认按微秒 return { time: int(round(self.time * time_unit)), param: self.param, value: self.value, interpolation: self.interpolation, }关键设计是时间单位。我一般会在加载时先采样一个关键帧的时间值做探测如果绝对值超过十万基本断定是微秒因为正常剪辑不会有人把关键帧放在十万秒之外。探测到的单位记录下来写回时用同一单位换算这样就不会出现读的时候按秒、写的时候按微秒的乌龙。def _extract_keyframes(fragment: dict) - list[Keyframe]: 兼容不同版本的剪映草稿关键帧字段 keyframes fragment.get(keyframes, fragment.get(animations, [])) if not keyframes: return [] time_unit 1_000_000 if abs(keyframes[0][time]) 100_000 else 1 result [] for kf in keyframes: for param in (scale, x, y, rotation): if param in kf: result.append(Keyframe( fragment_idfragment.get(id, ), paramparam, timekf[time] / time_unit if time_unit ! 1 else kf[time], valuekf[param], interpolationkf.get(interpolation, linear), )) return result逻辑说明这个函数只做一件事——把剪映原始的关键帧数组变成统一的 Keyframe 列表。param 循环里探测四个常见参数名遇到哪个收录哪个。fragment_id 用于关联 UI 左侧的片段列表批量操作时按这个 ID 过滤。把 value 统一按比率数存比如缩放 0.5 就存 0.5等到 UI 层显示时再乘 100避免小数和百分数混用。3.3 批量操作整体偏移、区间复制、参数替换模型归一化之后批量操作就变成纯数据计算这也是整个项目最值钱的部分。剪映里手动逐条拖关键帧要几十次的重复劳动在这里只是一个循环。# core/keyframe_ops.py def shift_keyframes(keyframes: list[Keyframe], fragment_ids: list[str], param: str | None, delta: float) - int: 把指定片段的指定参数关键帧整体平移delta秒不能移出0点 changed 0 for kf in keyframes: if kf.fragment_id not in fragment_ids: continue if param is not None and kf.param ! param: continue new_time kf.time delta if new_time 0: continue kf.time new_time changed 1 return changed逻辑说明fragment_ids 是目标片段 ID 列表param 传 None 表示所有参数一起偏移。跳过 new_time 小于 0 的关键帧是为了保证动画不往素材起点前面跑这个边界条件如果不处理写回后剪映里的关键帧会落在负时间上直接异常。返回 changed 数量UI 可以在状态栏显示已偏移 12 个关键帧用户能明确感知操作生效了多少。区间复制是另一个高频功能比如把第一段素材的入场缩放动画复制给后面四十段def copy_range(src_fragment: str, dst_fragments: list[str], keyframes: list[Keyframe], start: float, end: float, copy_offset: float 0.0) - int: 把某个片段在[start,end]区间内的所有关键帧复制到目标片段 added 0 src_kfs [kf for kf in keyframes if kf.fragment_id src_fragment and start kf.time end] for dst in dst_fragments: for kf in src_kfs: keyframes.append(Keyframe( fragment_iddst, paramkf.param, timekf.time copy_offset, valuekf.value, interpolationkf.interpolation, )) added 1 return addedcopy_offset 参数用来做错峰。所有素材用同一个动画节奏会显得很机械给每段加 0.1 到 0.3 秒的随机偏移视觉上就是每段素材入场时间稍微错开这是模板批量套用里很实用的小技巧。这两个操作都是纯函数式修改入参列表不改外部状态测试起来非常舒服。4. 桌面界面与安全写回时间轴画布、表格编辑与备份策略4.1 主窗口布局片段列表、时间轴画布、参数表格三栏界面用 QSplitter 做三栏布局左边是片段列表中间是时间轴画布右边是关键帧参数表格。三个组件共享同一个 Keyframe 模型所以任何一处改动都会同步到其他两处。# ui/main_window.py from PyQt5.QtWidgets import QMainWindow, QSplitter, QListWidget, QTableView class MainWindow(QMainWindow): def __init__(self): super().__init__() self.splitter QSplitter() self.fragment_list QListWidget() # 左素材片段列表 self.timeline TimelineWidget() # 中时间轴画布 self.keyframe_table QTableView() # 右关键帧参数表格 self.splitter.addWidget(self.fragment_list) self.splitter.addWidget(self.timeline) self.splitter.addWidget(self.keyframe_table) self.setCentralWidget(self.splitter)布局逻辑很简单但数据同步是重点。左侧片段列表选中一项时中间时间轴只画该片段的关键帧曲线右侧表格也只显示该片段的关键帧行。这样做还有一个性能好处大项目草稿可能有上万关键帧如果全部画进时间轴QGraphicsScene 会扛不住按片段过滤后场景里同一时间最多只有几十个图元界面始终流畅。4.2 时间轴交互鼠标拖拽改关键帧时间点时间轴画布是项目里交互最密集的模块。关键帧画成圆点用户按住拖动可以改时间框选可以选中多个点滚轮缩放秒级精度。# ui/timeline_widget.py from PyQt5.QtWidgets import QGraphicsView, QGraphicsScene from PyQt5.QtCore import Qt class TimelineWidget(QGraphicsView): def __init__(self, parentNone): super().__init__(parent) self._scene QGraphicsScene() self.setScene(self._scene) self._pixels_per_second 80 self.setDragMode(QGraphicsView.RubberBandDrag) def mousePressEvent(self, event): super().mousePressEvent(event) item self.itemAt(event.pos()) if item and event.button() Qt.LeftButton: self._dragging_keyframe item self._drag_start item.pos() def mouseMoveEvent(self, event): if self._dragging_keyframe is not None: scene_pos self.mapToScene(event.pos()) new_time scene_pos.x() / self._pixels_per_second self._dragging_keyframe.setTime(new_time) self.scene().update() super().mouseMoveEvent(event)逻辑说明鼠标按下时itemAt 判断是否点中了某个关键帧图元拖拽过程中mapToScene 把视图坐标换算成场景坐标再用场景 x 坐标除以 pixels_per_second 得到时间秒数。pixels_per_second 是缩放系数初始 80 表示 1 秒占 80 像素滚轮事件里调整这个值就能实现时间轴缩放。setTime 最终落到模型的 Keyframe.time 上表格会通过信号刷新三栏数据同步。框选则完全交给 QGraphicsView 自带的 RubberBandDrag 模式选中的图元集合再传给右侧表格做高亮这一步不需要自己写拾取逻辑是 PyQt5 相比 tkinter 最省力的地方。4.3 写回草稿备份、临时文件与原子替换写回是整个项目最危险的一步因为草稿文件被剪映自己打开过、被外部工具改过都可能造成不可逆损坏。DraftWriter 用三个手段兜底先备份、写临时文件、原子替换。# core/draft_writer.py import json import shutil from pathlib import Path class DraftWriter: def __init__(self, keep_backup: bool True): self.keep_backup keep_backup def backup(self, content_path: Path): if not self.keep_backup: return bak content_path.with_suffix(.json.bak) shutil.copy2(content_path, bak) def merge_and_write(self, raw_draft: dict, content_path: Path, updated_keyframes: list[Keyframe]): for fragment in raw_draft.get(clips, []): fid fragment.get(id, ) kfs [kf.to_draft_format() for kf in updated_keyframes if kf.fragment_id fid] if kfs: fragment[keyframes] kfs tmp_path content_path.with_suffix(.json.tmp) with tmp_path.open(w, encodingutf-8) as f: json.dump(raw_draft, f, ensure_asciiFalse, indent2, allow_nanFalse) tmp_path.replace(content_path)逻辑说明备份用 copy2 保留原文件完整属性merge 环节只动 keyframes 字段草稿里其他一切字段原样保留避免误删先写 .tmp 再 replace是因为 replace 在同一个文件系统内是原子操作中途断电或报错都不会把原文件写一半。allow_nanFalse 是很容易忽略的细节JSON 标准里没有 NaNPython 的 json.dump 默认会宽松地写进去剪映的解析器遇到这种值会直接判草稿损坏这里强制抛异常反而安全。参数说明keep_backup 开关默认开如果用户确认草稿有版本管理或者不想留备份文件可以在 config 里关掉。每次保存都生成一个 .bak时间久了会积累项目在 main_window 里对 .bak 做了按日期清理的逻辑只保留最近三份。5. 避坑记录解析剪映草稿时踩过的五个坑以下五条是拆这个项目、以及拿它批量改草稿时实际翻车记录下来的每条按现象、原因、解决三个步骤写后面做类似工具可以直接跳过。1. 改完草稿剪映打开一直转圈提示草稿损坏现象在桌面版里把某个关键帧的 value 改成空值后保存剪映打开草稿一直转圈最后弹窗提示草稿已损坏。重新打开备份文件才恢复。原因JSON 标准里根本没有 NaN 和 Infinity 这两个值但 Python 的 json.dump 默认会把它宽松地序列化成非法文本写进文件。剪映的 JSON 解析器是严格模式遇到这类值直接异常。用户把参数清空或插值计算溢出时最容易产出这种脏数据。解决写回时强制 allow_nanFalse一旦出现非法数值立刻抛 ValueError而不是把脏数据写进磁盘。配合捕获异常后自动回滚备份文件把静默写坏文件变成显式报错恢复try: json.dump(raw_draft, f, ensure_asciiFalse, indent2, allow_nanFalse) except ValueError as e: logger.error(关键帧里有非法数值回滚备份: %s, e) shutil.copy2(bak, content_path)2. 关键帧时间轴错位动画全部提前或延后现象在桌面版里把整段动画偏移两秒写回后在剪映里看关键帧落在 0.2 秒的位置完全对不上。原因草稿里 time 字段单位不统一。有的版本存微秒6000000 表示 6 秒有的按帧存有的干脆是秒。加载时全当秒解析写回又按微秒转换两个错误叠加时间点自然乱套。解决加载时采样关键帧数组的第一个值绝对值超过十万就判定为微秒把单位记录在模型元数据里写回时用同一个单位换算回去。核心逻辑就是 3.2 节里的 time_unit 探测这个判断虽然看起来像玄学但在实际草稿上跑了几十个样本都没出过错。3. 程序扫不到草稿候选列表永远是空的现象启动后候选草稿列表空白怎么刷新都没有数据但剪映里明明有项目。原因剪映的草稿目录在不同系统、不同安装方式下路径不一样。项目默认只配了一个固定路径用户机器上草稿存在别的位置rglob 扫不到。解决把 config.json 里的草稿目录改成数组支持多个根路径首次运行弹文件对话框让用户手动选一次剪映草稿所在的根目录写进配置后以后自动扫描。记住一个原则文件扫描只做发现不做假设路径一律可配置。4. 批量替换 scale 参数后所有素材画面比例异常现象把某段素材的缩放关键帧复制给另外四十段后剪映里的素材要么缩成一点要么撑满全屏和预期完全不符。原因草稿里 transform 的 scale 存的是比率数0.5 表示 50%界面显示习惯用百分数 50。模型层没统一直接把 50 当比率数写回缩放就变成了 50 倍。这是典型的单位混用问题。解决模型层固定用比率数存储UI 层显示时乘 100写回时再除 100。三层换算只允许在边界做模型内部所有运算都用同一套单位这条规则我从那以后每次都强制执行。5. 打开多轨长项目界面冻结十几秒现象载入一个几十个轨道、上下个关键帧的长项目界面卡住十几秒拖动时间轴掉帧严重。原因加载逻辑把所有关键帧一次性 new 成 QGraphicsItem 塞进场景图元数量上万QGraphicsScene 的场景管理和碰撞检测全部变慢。解决时间轴只加载当前选中片段的关键帧切换片段时清空场景重画视图缩小到一定比例时只画连接线不画圆点做 LOD 降级。这样场景里同一时间最多只有几十个图元大项目也流畅。6. 二次开发把它改造成模板批量套用工具拿到源码后最常见的改造方向是把编辑器变成批量套模板工具。核心思路是利用已经写好的 copy_range 和 shift_keyframes先录制一段素材的关键帧动画作为模板曲线然后批量复制到所有目标素材再随机做一点时间错峰避免所有片段动画节奏完全一致。# 二次开发示例模板批量套用 template_frag clip_001 target_frags [fclip_{i:03d} for i in range(2, 42)] random_offset random.uniform(-0.2, 0.2) copy_range( src_fragmenttemplate_frag, dst_fragmentstarget_frags, keyframesmodel.keyframes, start0.0, end2.0, copy_offsetrandom_offset, ) shift_keyframes(model.keyframes, target_frags, scale, -0.3)这段代码把第一段素材 0 到 2 秒区间内的所有关键帧曲线复制给第 2 到第 41 段素材每段带一个正负 0.2 秒的随机偏移然后再把它们的 scale 关键帧整体提前 0.3 秒形成入场更早、节奏错开的动画效果。原来在剪映里手动操作这些每段素材至少两分钟现在跑一次脚本几秒钟完成。验证方法不要跳过项目自带的 tests/test_draft_roundtrip.py 回环测试要跑一遍确认写回前后的 JSON 除了预期改动外没有丢字段再把新旧草稿文件做一次 diff检查 keyframes 的 diff 结果是否只有时间和数值变化。最后才到剪映里播放逐段看动画节奏。如果想更进一步可以把 value 从固定数值改成由外部算法生成。比如读取音频响度数据把响度映射成缩放值自动生成声音越大画面越放大的卡点动画。因为模型层已经和 GUI 解耦加这个功能只需要在 core 目录里新增一个 audio_mapper.py把算好的 value 填进 Keyframe 列表即可一个 UI 组件都不用改。从拿到这份源码到做完这个改造我大概用了两天从那以后每次改草稿都强制走一遍先备份、改完跑回环测试、再 diff三步这个习惯帮我拦下过好几回低级错误。希望帮到你。本文还有配套的精品资源点击获取