1. 先说结论:这个 Skill 是怎么把宣传片成本打下来的
最近我开源了一个叫product-video-skill的项目,思路很简单:用一个可复用的 Skill 包,把“产品宣传片”从“花钱找外包、熬夜剪片子”变成“写几行配置、跑一条命令,自动出片”。如果你对 Agent Skill 这个机制还不熟,可以先把它理解成“给 AI Agent 装的一个专业技能包”——里面包含说明文档、脚本、模板和素材,Agent 接到任务时自动加载这套流程,按步骤执行,而不是靠临场发挥。这个 Skill 就是专门干宣传片这一件事的。
它能解决什么问题?最直接的是成本问题。市面上一段 30 秒的成品宣传片,外包报价少则几千,多则上万;用在线模板也只能拼出千篇一律的“套壳感”。而我这套方案,只要你本地有 Python、FFmpeg 和一个能调用的 LLM API,再加上开源字体和免版权素材,就能用代码生成一条结构完整、有配音、有字幕、有转场的宣传片。整个过程花的时间大概就是“改配置 + 跑命令”,单条视频的边际成本趋近于零。
适合谁用?我主要面向这几类人:独立开发者要给自己的开源项目或 SaaS 工具做一条介绍短视频;小团队没有专职视频设计师,但又需要在发布会、官网、社媒上快速产出物料;还有想批量做测评视频、课程宣传片的运营同学。如果你手上有明确的产品素材,又不想被剪辑软件的学习成本劝退,那这个 Skill 会比较对胃口。后面我会把完整的目录结构、核心脚本、运行流程和踩坑记录都摊开来讲,尽量让一个只写过 Python 的人也能照着复现。
2. 整体设计:一个“专业剪辑流程”如何被拆成可执行模块
2.1 设计原则:让 Agent 产出标准化工作流,而不是自由发挥
宣传片虽然看起来是创意活,但它的内部结构其实非常模板化:开场钩子、产品痛点、核心功能、使用场景、价值总结、行动号召,这是不管什么产品都逃不掉的六个节点。既然有规律,就能标准化。所以我没有让 Agent 从零“设计”一部片子,而是先定义好分镜模板,再让 Agent 负责往模板里填充产品文案和画面描述。
这个思路是受“规则引擎 + 大模型”混合架构的启发:规则负责稳定,模型负责创造。如果完全让大模型自由发挥,你会发现每次生成的视频结构都不一样,有的漏了行动号召,有的把产品名写错,有的画面比例不符合平台要求。而把结构固定成 JSON Schema 后,Agent 再怎么自由,产出的分镜也能保证“该有的部分一个不少”。
2.2 目录结构:一个 Skill 包就是一个小型工程
这是我的项目目录,信我,这东西长得像个小工程,但每个文件职责非常明确:
product-video-skill/ ├── SKILL.md # Skill 入口文档,Agent 首先读取这里 ├── config/ │ ├── product.yaml # 产品信息配置:名称、卖点、目标人群 │ └── render.yaml # 渲染参数:分辨率、帧率、字体、背景音乐 ├── scripts/ │ ├── generate_storyboard.py # 调用 LLM 生成分镜 JSON │ ├── render.py # 用 FFmpeg 把分镜渲染成视频片段 │ └── assemble.py # 拼接片段、合成字幕和配音 ├── templates/ │ ├── storyboard_template.json │ └── subtitle.ass # 字幕样式模板 └── assets/ ├── fonts/ # 开源中文字体,比如思源黑体 └── bg/ # 免版权背景视频和音乐先说SKILL.md。它是整个 Skill 的“说明书”,里面写了这个 Skill 的触发条件、输入输出格式、执行步骤和注意事项。Agent 在被问到“给产品生成宣传片”时,会主动读取这个文件,按里面的流程走。它的作用不是给你看,而是给 AI Agent 看,所以写法要像一份“操作手册”:
- 明确输入:需要一个
product.yaml文件路径; - 明确输出:产出一个 MP4 文件,放在
output/目录; - 明确步骤:先读取配置 -> 生成分镜 -> 准备素材 -> 逐段渲染 -> 合成;
- 明确禁忌:不要使用平台不支持的字体,不要生成超过指定时长的视频。
2.3 为什么把渲染交给 FFmpeg,而不是让 Agent 直接“产视频”
大模型目前没法直接生成长视频,即使能生,成本也高得离谱。所以我的方案是分层处理:LLM 负责“想”,FFmpeg 负责“画”。LLM 生成的其实是分镜描述,比如“淡蓝色科技感背景,画面中央出现产品 Logo,下方显示 Slogan”;然后 Python 脚本把这些描述翻译成 FFmpeg 的 filter 参数,最终渲染出画面。
这样做还有个好处:FFmpeg 是纯本地计算,不消耗 API token,也不受生成模型的速率限制。视频渲染的每一帧都是确定性的,不会出现“同一段文案两次生成画面不一样”的问题。对于产品宣传片来说,确定性很重要,因为你可能改了文案后需要重新出片,如果每帧都随机生成,那根本没法做版本管理。
3. 核心实现:从产品信息到分镜脚本的关键代码
3.1 产品配置:把“人话”写成 YAML
一切从配置开始。我用一个product.yaml描述产品的基本信息,这个文件也是整个 Skill 的唯一“必须手写”的东西。它的设计刻意做得很“懒人友好”,不需要任何剪辑知识:
product: name: "PicKit" slogan: "让产品截图变成营销素材" description: "PicKit 是一个开源的截图美化工具,支持自动抠图、背景替换、批量导出。" target_users: ["独立开发者", "市场运营", "电商卖家"] pain_points: - "产品截图太丑,放官网没质感" - "设计软件学习成本高" highlights: - title: "自动抠图" desc: "上传截图即可去除纯色背景" - title: "智能排版" desc: "内置 20 套营销模板" call_to_action: "前往 GitHub 免费下载" style: primary_color: "#3B82F6" background: "tech-gradient" tone: "professional"注意highlights这个字段,它是分镜脚本的核心素材。每个 highlight 会成为视频里的一个独立分镜。我在模板里限定了 3 到 5 个,因为 30 秒左右的宣传片塞太多信息,观众根本记不住。你只需要维护这份配置,剩下的脏活累活都交给脚本处理。
3.2 分镜生成:让大模型按模板输出 JSON
接下来是generate_storyboard.py。这个脚本做的事情,说人话就是:把product.yaml的内容拼成一段 Prompt,发给支持 OpenAI Chat Completions 协议的 API,让它严格按照storyboard_template.json的格式返回一个分镜 JSON。
为了稳,我不会让模型直接输出自由文本,而是先定义一个 JSON Schema,并且在请求里明确要求“只输出 JSON,不要解释”。模型返回后,脚本还会做一次本地校验,检查必填字段是否存在,缺了就直接报错,而不是带着残缺数据往下跑。
import json import sys import yaml from openai import OpenAI def load_config(path: str) -> dict: with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def build_prompt(config: dict) -> str: return f""" 你是产品短视频策划。请根据以下产品信息,生成一段 30 秒宣传片的分镜脚本。 要求:每个分镜包含 id、duration、visual、voiceover、subtitle 字段。 visual 是对画面的视觉描述,会交给 FFmpeg 渲染,请描述颜色、构图、动效。 voiceover 是配音文本,subtitle 是屏幕字幕,subtitle 要短于 voiceover。 产品信息: {json.dumps(config, ensure_ascii=False, indent=2)} 只输出 JSON 数组,不要输出任何其他内容。 """.strip() def generate_storyboard(config: dict, api_key: str, base_url: str) -> list: client = OpenAI(api_key=api_key, base_url=base_url) resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": build_prompt(config)}], response_format={"type": "json_object"}, ) raw = resp.choices[0].message.content data = json.loads(raw) scenes = data["scenes"] if isinstance(data, dict) else data validate_scenes(scenes) return scenes def validate_scenes(scenes: list) -> None: required = {"id", "duration", "visual", "voiceover", "subtitle"} for scene in scenes: missing = required - set(scene.keys()) if missing: raise ValueError(f"分镜缺少字段: {missing}") if __name__ == "__main__": cfg = load_config(sys.argv[1]) api_key = sys.argv[2] base_url = sys.argv[3] scenes = generate_storyboard(cfg, api_key, base_url) with open("output/storyboard.json", "w", encoding="utf-8") as f: json.dump(scenes, f, ensure_ascii=False, indent=2)这里有个很关键的经验:response_format={"type": "json_object"}不是所有 API 都支持,如果你的模型服务不支持这个参数,就去掉它,然后让脚本做容错解析——把返回文本里第一对{...}或[...]截取出来再json.loads。我在项目里两种方式都做了兼容,宁可多写 20 行防御代码,也不要半夜被用户的乱数据搞崩。
3.3 渲染脚本:把“视觉描述”翻译成 FFmpeg 滤镜
render.py是整个 Skill 里工程量最大的一块。它不是简单调一次 FFmpeg,而是会遍历分镜,给每个分镜生成一个独立的短视频片段,每个片段再根据不同场景类型套用不同的视觉模板。我在项目内置了三种视觉模板:
- 产品高亮模板:渐变背景 + 产品截图从中间放大入场 + 下方字幕条上浮;
- 痛点展示模板:深色背景 + 大字号痛点文案居中 + 轻微缩放抖动;
- 行动号召模板:主色背景 + Slogan 放大 + 网址从底部滚动进入。
核心是用 FFmpeg 的zoompan实现缓慢推镜,再用drawtext叠加字幕,最后用color和gradients源生成动态背景。下面是一个“产品高亮”片段的实际 filter 参数:
ffmpeg -y -loop 1 -i assets/bg/tech.jpg -i assets/img/product_shot.png \ -filter_complex " [0:v]scale=1920:1080,zoompan=z='min(zoom+0.0015,1.15)':d=125:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1920x1080:fps=25[bg]; [1:v]format=rgba,scale=800:-1,zoompan=z='min(zoom+0.003,1.2)':d=125[logo]; [bg][logo]overlay=(W-w)/2:(H-h)/2-60[v0]; [v0]drawtext=fontfile=assets/fonts/SourceHanSansCN-Bold.otf:text='自动抠图':fontsize=72:fontcolor=white:x=(w-text_w)/2:y=h*0.68:enable='between(t,0.5,5)'[vout] " -c:v libx264 -pix_fmt yuv420p output/scene_0.mp4这段命令看起来复杂,拆开看其实就三层逻辑:第一,让背景图做缓慢推近,产生“镜头在动”的感觉;第二,把产品截图叠在画面中央;第三,在字幕位置写上一句功能标题。为什么用d=125?因为 5 秒的分镜,按 25 帧每秒算就是 125 帧,d要跟分镜时长严格匹配,否则最后合成出来的视频会多出几帧黑屏。
另一个细节是drawtext的:enable='between(t,0.5,5)'。我故意让字幕延迟 0.5 秒出现,这样观众先看到画面,再看到文字,视觉重心不会打架。这是剪辑里常见的“先画面后文字”原则,很多人第一次写脚本会忽略,结果字幕和画面同时出现,观感非常生硬。
3.4 合成阶段:配音、字幕、背景音乐一次搞定
分镜片段渲染完后,assemble.py负责把它们拼起来。拼的时候不只是concat,还要做三件事:生成背景音乐轨道、生成配音轨道、压制字幕。
配音我默认走 TTS,也就是让脚本读取每个分镜的voiceover文本,调用一个本地的 TTS 引擎生成 WAV。字幕用 FFmpeg 的subtitles滤镜加载 ASS 模板,我会预先把字幕样式字号、颜色、位置都写在subtitle.ass里,这样不会出现“系统字体不支持中文”的经典翻车。
最终的合成命令大概是这样的:
ffmpeg -y \ -f concat -safe 0 -i output/scenes.txt \ -i output/voiceover.wav \ -i assets/bg/music.mp3 \ -filter_complex " [1:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[voice]; [2:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo,volume=0.25[bgm]; [voice][bgm]amix=inputs=2:duration=first[aout]; [0:v]subtitles=output/subtitle.ass[vout] " \ -map "[vout]" -map "[aout]" \ -c:v libx264 -c:a aac -b:a 192k \ -movflags +faststart \ output/final_video.mp4这里我特意把背景音乐音量压到 0.25,也就是 -12dB 左右。原因很简单:背景音乐是陪衬,不是主角,人声必须清晰。你可以在render.yaml里调这个值,但我见过太多人默认音量直接盖过配音,所以我干脆在模板里写死一个保守值,想调再自己改。
4. 实操记录:跑一条命令,得到一条 30 秒成品
4.1 环境准备清单
在真正跑通之前,先把环境准备好。我建议用虚拟环境,别把依赖直接装进系统 Python,否则过了两个月你自己都可能忘了当时装了什么。
git clone https://github.com/yourname/product-video-skill cd product-video-skill python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txtrequirements.txt里只有openai、pyyaml、pillow,剩下的视频能力全靠 FFmpeg。安装 FFmpeg 这块,不同系统方法不一样,macOS 用brew install ffmpeg,Ubuntu 用apt install ffmpeg,Windows 直接下载官方编译版并配置 PATH。装完以后执行ffmpeg -version,只要能看到版本号,就说明环境没问题。
4.2 修改配置文件
打开config/product.yaml,把 PicKit 的示例内容换成你自己的产品信息。这里我建议你遵守几条规则:
highlights里写 3 到 5 个,别超过 5 个;- 每条 highlight 的
title控制在 6 个字以内,字幕显示不下; slogan和call_to_action分开写,slogan 是情感向的,CTA 是行动向的,别混在一起。
我踩过一次坑:把 CTA 写成了“前往官网下载并查看更多信息”,结果字幕在 5 秒内根本放不下,字体自动缩小到看不清。后来我把模板里 CTA 的字符数上限写死为 12,超出就报错提醒。
4.3 执行生成流程
准备工作做完,就三步:
python scripts/generate_storyboard.py config/product.yaml $OPENAI_API_KEY https://api.example.com/v1 python scripts/render.py output/storyboard.json python scripts/assemble.py output/第一条命令会生成output/storyboard.json,你可以先打开看看 AI 写的分镜脚本靠不靠谱。如果文案风格不对,就去改product.yaml里的tone字段,把它从professional改成playful或minimal,然后重新跑第一条命令。第二条命令把所有分镜渲染成片段,耗时取决于机器性能和分镜数量,我的 M 系列芯片大概 20 秒一个场景。第三条命令负责合成。
如果你不想分三步跑,我也提供了一个一键入口:
python scripts/run.py --config config/product.yaml这个run.py其实就是依次调用上面三个脚本,并且在关键步骤之间加了状态检查。比如生成的故事板文件不存在时,它会明确告诉你“先生成分镜”,而不是在渲染阶段抛一个看不懂的 FFmpeg 报错。
4.4 怎么调参数,才能让视频看起来“不那么粗糙”
机器生成的视频,最容易露馅的地方有三个:帧率、节奏和转场。
帧率我建议用 25 或 30,不要用 24。因为 25 帧每秒是 PAL 制基准,很多在线视频平台会自动做转换,24 帧在部分平台会出现微妙的不同步。转场不要一味用硬切,我习惯在分镜之间留 0.5 秒交叉淡化,这样观感会柔和很多。节奏方面,一个分镜的时长建议控制在 3 到 6 秒,少于 3 秒观众根本反应不过来,多于 6 秒又会觉得拖沓。
还有一个小技巧:在render.yaml里把分辨率预设成 1920x1080,但最终上传时如果只是发朋友圈或公众号,可以再压一版 1080x1080。为什么?因为竖屏或方形视频在手机上的完播率更高。渲染脚本支持canvas参数,改成square_1080就会自动把背景裁剪成方形,所有字幕位置也会跟着调整,不会出现“画面变形但字幕还在原位置”的智熄问题。
5. 常见问题速查与避坑笔记
我把实际使用中遇到的高频问题整理成了表格,不夸张地说,这些坑我基本都亲手踩过。
| 问题现象 | 原因 | 解决方案 |
|---|---|---|
| 字幕全是方框/乱码 | 系统没有中文字体,或 drawtext 字体路径不对 | 把开源字体放进 assets/fonts,并在 render.yaml 指定绝对路径;推荐思源黑体 |
| drawtext 报错 parse failed | 文案里有冒号、引号等特殊字符 | 先转义特殊字符,或者改用 subtitles 滤镜走 ASS 文件,我推荐后者,一劳永逸 |
| zoompan 画面抖动 | zoom 增量过大或 d 参数与帧数不匹配 | 增量改成 0.0015 以内,d 必须等于时长 * 帧率;表达式里保留足够小数位 |
| 视频没有声音 | 配音和音乐 amix 时 duration 参数用了默认值 | 加上duration=first,并确保两个音轨采样率一致 |
| LLM 返回的不是 JSON | 模型不遵守 prompt,或服务不支持 response_format | 脚本里做容错提取:找到第一个[或{,截断到最后一个匹配的]或},再解析 |
| 生成的画面千篇一律 | 背景素材太少,模板视觉单一 | 多放几张背景图和视频素材;或按产品行业切换背景关键词 |
| 视频体积太大 | 码率设得过高 | 用-crf 23代替固定码率,画质够用且体积小一半 |
除了表格里的,我还想再多说三个心得。
第一个心得:不要过度依赖 LLM 生成画面。目前的模型生成图片/视频,对产品 Logo 和界面细节还原很差。我的策略是产品截图永远用真实的 PNG 素材,LLM 只负责生成背景和配色方案。换句话说,把“带真实信息的画面”和“纯氛围的画面”分开,前者用素材,后者用生成。
第二个心得:先测试一分半钟的片段,再跑全片。我在开发时吃了大亏,直接让脚本生成 6 个场景,结果第三个场景的字幕字体没找到,整个过程白跑。后来我在render.py里加了一个--test参数,只渲染第一个分镜,5 秒内就能确认字体、色彩、音频通道都没问题,再跑全片。省下的时间比写这个功能的时间多得多。
第三个心得:版本管理要跟上。宣传片文案改一个词,整个视频就要重出。我建议在output/目录里按日期建子目录,比如output/20250120_v2/,不要覆盖旧版本。否则客户说“还是上一版好看”,你已经在原文件上反复压制了五遍,根本找不回来。
6. 我自己的使用体会和下一步想加的东西
这个 Skill 在写完之后,我先拿自己的开源项目试了一条片子。效果当然没法跟专业动画工作室比,但作为产品官网首页的轮播视频、GitHub README 里的展示素材、或者发在社交媒体上的预热短视频,完全够用。我个人在实际使用中最满意的点是:改文案的成本几乎为零。以前视频团队改一句话可能要重新渲染半小时,我这里改一行 YAML 再跑命令,两分钟就出片,所以我可以做很多个版本的 A/B 测试,把不同 Slogan 放上去看哪条转化好。
当然,它目前还比较“线性”。每个分镜都是固定的视觉模板,没有跳出“背景 + 截图 + 字幕”的舒适区。我下一步想给它加一个“自动收集素材”的步骤:让 Agent 根据分镜脚本去指定的目录里找图片、录屏、截图,按优先级顺序填进对应画面槽位。想法是把目前需要人工准备的素材环节也半自动化掉,让用户只给一个很粗糙的产品介绍文档,就能出片。
最后再分享一个小技巧:如果你在做系列视频,比如每周一期产品更新,那我建议把product.yaml做成一个长期维护的“产品主数据文件”,每周只改highlights和call_to_action,其他内容保持不变。这样你产出的所有视频在品牌色、产品介绍、背景风格上完全一致,长期积累下来的素材库会形成很强的辨识度。这也是我觉得用代码生成视频最大的优势——它不追求单条片的“惊艳”,但追求批量输出的“稳定”,而这个优势,恰好是内容团队最稀缺的东西。