把视频拆解工具焊进 MDX 博客仓库:read-video 工具的构建笔记
从一个独立的 Python 抽帧脚本,到把它内嵌进 next-mdx-portfolio 仓库,配套一份 AI 协作 skill,最终形成「丢一个视频路径,AI 自动写出 .mdx」的工作流。本文记录这一路上的取舍。
DinoMay 7, 2026
写宣传片拆解的最大苦活,不是写文章,而是反复在剪辑软件里来回拖时间轴抠细节。视频 5 分钟、文章 1500 字,写完要回看十几遍。
后来我做了个小工具替我看视频。
它的工作很笨:把视频按时间均匀切成 20 张图,写一份带空槽位的 Markdown 底稿,剩下的事交给 Cursor 里的 AI——读图、回填底稿、按本仓库的 MDX 规范写出成品。整套流程不依赖任何在线 AI API,关键模型部分用的是 Cursor 自己的视觉能力。
这篇文章记录了它的演进:从一个独立 Python 脚本,到把它整体焊进这个 next-mdx-portfolio 仓库,再到写一份 AI 工作流 skill 把人和模型都规训进同一条流水线。
这篇主要看四件事:
raw_frames.md 如何把「看图」和「写作」拆成两步最早的版本叫 Read video,是另一个独立的 Python 项目。核心就一个能力:调 FFmpeg 抽帧。
不依赖任何第三方 Python 库是有意为之。视频处理里那些花哨的 OpenCV、moviepy、PyAV 用起来都比 FFmpeg CLI 慢一截,而且打包麻烦。直接走 subprocess.run(["ffmpeg", ...]) 反而干净——只要本机装了 FFmpeg 加进 PATH,就能跑。
抽帧策略支持三种:固定张数(count)、固定帧间隔(frame)、固定秒间隔(second)。早期我以为后两种更实用,毕竟「每 30 帧一张」听起来很专业。真用下来发现:长视频会爆图,短视频又抽不到几张,每次都得手动调参。最后默认改成「全片均匀抽 N 张」,N 默认 20,正好覆盖大多数 5 到 10 分钟的宣传片。
这个版本能跑,但它和我的博客仓库是脱节的。每次写一篇拆解,都是这个流程:
独立项目里抽帧 → 看图 → 手写文章 → 复制到博客仓库 → 改 frontmatter → 找一张帧当封面 → 压视频 → 推到 public
中间七八步都靠肉手和复制粘贴,写完文章我都没力气检查路径有没有写错。
转折点是某次写完一篇文章,发现成品 .mdx 里的视频路径是 /Projects/Liblib AI/...(带空格),但实际目录是 /Projects/Liblib-AI/...(slug 化)。前端跑起来视频静静地白着。
这种事一旦发生过两次,就该考虑用代码消灭它,而不是靠人记住。
我把 Read video 整体迁进 tools/read-video/,作为仓库内嵌工具。这个决定有三个直接好处:
工具可以看见仓库本身。 原来的版本不知道「项目案例的目录命名要 slugify、博客 cover 要 webp、视频要进 video/ 子目录」这些约定,因为它是个通用工具。内嵌之后,它就可以把这些规则写成 Python 函数,CLI 一传 --target projects --company "Liblib AI" --slug demo,就能直接算出最终落点。
中间产物可以自然地被 gitignore 掉。 所有抽帧图片落到 <repo>/articles/<slug>/,这个目录早早进了 .gitignore,无论生成多少张都不会污染 git。等 AI 真要把某一帧搬进文章了,再手动复制到 public/.../frames/。
AI 协作变得可能。 Cursor 能直接看到这个工具,能 Read 它的代码、Read 它生成的底稿、按规则写 .mdx——前提是工具的行为和落点是确定且可推断的。
仓库里有两个落点:博客和项目案例。每个落点又分内容(.mdx)和静态资源(public/...)。算上 slug 的命名规则,这里至少有六七处可能写错的地方。我把它们全集中到一个 layout.py:
def repo_root() -> Path:
return Path(__file__).resolve().parents[3]
def article_workspace_dir(slug: str) -> Path:
return repo_root() / "articles" / slug
def posts_mdx_path(slug: str) -> Path:
return repo_root() / "content" / "posts" / f"{slug}.mdx"
def projects_public_dir(company: str, slug: str) -> Path:
return repo_root() / "public" / "Projects" / slugify(company) / slug
CLI 永远不直接拼路径,只调这些函数。projects_public_dir 这一行特别值——它把「content/projects/ 下用中文目录名(Liblib AI),但 public/Projects/ 下用 slug(Liblib-AI)」这条仓库历史约定吃下去了。AI 之后再怎么调用,都不会再写出空格目录。
slugify 也特意没去全英化。本仓库下大量中文文件名(如 115-Move Object 功能.mdx)已经存在,硬转 kebab-case 反而和现状冲突。规则简化为:删 Windows 不允许的字符、空格和中文标点统一换连字符、首尾压缩。中英文都保留。
抽帧只是搬砖。真正决定后续文章质量的,是中间那份 raw_frames.md。
最初的版本把它当成单纯的预览页:每帧一张图、一个时间戳,写完就完了。后来发现 AI 一旦读完图就开始凭印象编故事,写到第二节就忘了第一节的画面里到底有什么字。
新版底稿加了「空槽位」这个机制。每帧下方预先放一段结构化空表:
## #012 · 00:01:23.500

**原始信息**(看图后填写;未读过保持 `-`)
- 画面主体:-
- 出现的文字 / UI:-
- 关键动作 / 变化:-
- 镜头语言(景别 / 运镜 / 转场):-
- 备注:-
AI 读完一张图,就用 StrReplace 把那一段的 - 换成客观信息——只记画面里有什么文字、谁在做什么,不写评价、不写文学化描写。
这一招的效果意外明显。它把「看图」和「写作」拆成了两个阶段:第一阶段只负责把视觉信号固化成文字;第二阶段写文章时,AI 不用再回头看图,只需要读这份已经被自己整理过的底稿。每次写一段不确定的细节,回查的也是这份文字事实表,而不是那张永远充满歧义的图片。
底稿头部还有一段长长的使用说明,告诉 AI 这是底稿不是成品、空帧可以跳过、最终 .mdx 该写到哪里。这段说明是被 CLI 在生成文件时直接拼进去的,AI 第一次 Read 这个文件就会读到,不需要任何额外提示。
把视频塞进 public/ 是个甜蜜陷阱。<MdxVideo> 组件的好处就是它能直接吃 public/ 下的 mp4 在前端播。但宣传片素材的码率常常是 15 到 25 Mbps,一支 5 分钟的片子轻松 60 多 MB。推几个项目仓库就过百 MB 了。
我加了一条铁律:进 public/ 的视频默认压到 4 Mbps 总码率(视频 + 音频)。这是肉眼基本看不出区别、但仓库压力可控的甜点。
实现走 H.264 two-pass:
total_bps = _parse_bitrate(target_bitrate)
audio_bps = _parse_bitrate(audio_bitrate)
video_bps = max(200_000, total_bps - audio_bps)
maxrate_bps = int(video_bps * 1.15)
bufsize_bps = int(video_bps * 2)
从总码率里先扣音频(128 kbps),剩下给视频。maxrate 取 1.15 倍、bufsize 取 2 倍,是 H.264 ABR(平均码率)模式下常见的稳定值。第一遍只扫数据不写文件、丢到 NUL(Windows)或 /dev/null,第二遍才真正编码。
有个细节藏在容错里。压缩很可能跑到一半失败,如果直接覆盖目标文件,原素材就毁了。所以代码先写到同目录的临时文件,全跑完再 os.replace 原子替换。同目录是为了避免跨盘 rename 在 Windows 上的诡异行为:
tmp_fd, tmp_name = tempfile.mkstemp(
suffix=dst_path.suffix or ".mp4",
prefix=dst_path.stem + ".compress.",
dir=str(dst_path.parent),
)
这意味着 compress 子命令的源和目标可以是同一路径——「就地压缩」一个已经入库的视频不会出问题。这个能力后来被 skill 用来兜底:万一视频先于压缩流程被搬进 public/,AI 还能跑一句 python -m read_video.compress <path> <path> --bitrate 4M 把它压回标准码率。
工具到这一步已经能用了。但只有把工具放给 AI 自动调,才能彻底干掉「人记规则」这一环。
Cursor 的 skill 系统给了我一个干净的入口。我在 .cursor/skills/read-video/SKILL.md 里写了一份不到 400 行的工作流文档,完整覆盖:
最关键的是第一段——「触发条件」。Cursor 会根据 skill 的 description 段决定何时自动加载它。我把它写得很具体:用户在仓库里发出一个绝对路径的视频文件,或在对话框里拖一段视频并说「加到某项目」,立刻进入这条流水线,不要追问抽帧密度、风格、保存位置等已经默认好的参数。
这条「不要追问」是反复打磨出来的。早期 AI 总会先问三连问:「您希望抽多少张?」「保存到哪?」「需要压缩吗?」这种行为对工具型工作流是灾难——人每次都要敲回车确认默认值。skill 直接把所有默认值钉死,AI 看到视频路径就跑,跑错了再说。
skill 还规定了一个「不准偷懒」的反约束。本仓库里另一个 skill 叫 add-project,它的工作是只用一行占位 + 视频快速建一个项目页。但 read-video 明确写:用户拖视频说「加到某项目」时,视为发布级项目案例,必须走完整的「抽帧 → 回填底稿 → 写四段正文 → 入库压缩 → 生成封面」闭环,不得只用 add-project 的最小模板敷衍过去。这条约束写得很硬,是因为 AI 如果有偷懒的余地,它一定会偷懒。
工具能跑、流程能走,最后一个坑是写出来的文章一股 AI 味。
我把文风要求也写进了 skill:
还专门列了一份 AI 高频词黑名单:「赋能 / 闭环 / 锚点 / 心智 / 范式 / 落地 / 抓手 / 体感 / 沉浸式」——能换成具体描述就换。
这一段的效果是渐进的。新模型一开始仍会偶尔写出「这套方案为内容生产赋能...」这种话,但 skill 配合参考文章(content/projects/Liblib AI/libtv-720-panorama-feature.mdx)一起加载之后,输出的文章已经能稳定保持在「方法论赏析博客」的口吻上。
整套东西现在是这样在工作的:
整个流程下来,我在键盘上的输入是:把视频拖进对话框、敲一句话、等几分钟、扫一遍成品做小修。
写到这里你可能会觉得这只是个普通的 Python 小工具加几份 markdown 配置文件。确实如此。但它教我一件事:当一个工作流足够确定,就该把每一条不确定性都吃掉——路径用函数算、约定写进 layout、AI 行为靠 skill 约束、文风用黑名单兜底、压缩失败有原子回滚。剩下来的不确定性,才是真正值得人花时间的地方。
先把画面事实固定下来,再开始写文章。
我只说一句「加到 Liblib AI 的项目案例」,不再追问抽帧密度、保存目录或压缩参数。
AI 自动加载 read-video skill,跑 python -m read_video "<路径>" --target projects --company "Liblib AI" --slug "<slug>" --copy-to-public。
工具完成抽帧、压缩和入库视频,同时写出 articles/<slug>/raw_frames.md,把路径和资源位置都固定下来。
AI 读 20 张帧、回填底稿、选关键帧做封面、按四段结构写出 .mdx,最后再更新 CHANGELOG_AI.md。