如果你最近也刷到了“用 Codex 生成脚本、再让 DeepSeek 写词、最后自动合成 PV”这类玩法,大概率会在评论区看到两种声音:一种是“这不就是套了个壳”,另一种是“这套流水线真能省下大半天”。两边说的其实都对,区别只在于你站在哪一种使用姿势里。如果只是把 AI 当聊天框,临时问一句、抄一段,那它确实只是个“壳”;但如果你把提示词、模型调度、输出解析、字幕和视频渲染全部固定成一个可复用的自动化管道,那你手里这个东西就值得叫 harness——它不是一个工具,而是一套围绕大模型搭建的生产系统。
这篇文章要讲的,正是怎么从零搭一个“填词 PV 自动化管线”:让 Codex 类模型负责工程脚手架和脚本生成,让 DeepSeek 负责歌词续写、断句、分镜描述和字幕文本,再用 FFmpeg 把结果拼成带字幕的视频。标题里那种“特供版”玩法,本质上就是给这套管线预设好固定的角色设定、语气偏好和内容边界,然后机器按这个配置批量出片。读完你至少能跑通一条最小链路,并且知道常见的坑藏在哪。
1. 为什么做 AI 内容生成需要一套 harness
先说一个很多人踩过的误区:以为大模型能直接输出成品视频。实际上,大模型能稳定输出的只有文本,而一段 P V 需要的是五类内容同时对齐——歌词每句话的起止时间、断句位置、对应画面描述、字幕样式、音频长度。靠人手动把这些粘到一起,哪怕只做一分钟的片段,也要反复调整十几次。
所谓 harness,翻译过来就是“线束”或“控制装置”。在 AI 工程领域,它通常指把模型调用、提示词模板、上下文组装、输出解析、后处理脚本、缓存和日志全部封装起来的那层代码。你可以把它理解成一条组装流水线:原料是角色设定和选题方向,中间经过若干个工位,每个工位调用不同模型或脚本,最终出来的是可以直接发布的 mp4。
过去你写一段歌词,要在聊天窗口里反复调整语气,然后复制到剪映里手动断句,再一句句配字幕。这个过程的问题不是“AI 能力不够”,而是“信息在人和工具之间来回搬运”,每搬运一次就丢失一点结构。采用 harness 之后,歌词、断句、时间码、分镜说明都是结构化数据,模型输出完直接被下一道工序消费,人工介入点从“每一步”缩到“只在最终预览时检查”。
所以它真正降低的并不是“词写得好不好”这件事,而是“从文本到成片”的工程成本。对于做短视频、二创、教育类解说、甚至是企业内部培训视频的人来说,这套思路比单次对话式的 AI 玩法实用得多。
2. Codex 与 DeepSeek 的角色分工
这个项目名字里同时出现了 Codex 和 DeepSeek,核心不是“二选一”,而是让它们各干各擅长的事。
Codex 类模型被训练时重点解决的是“把一个自然语言任务转成可执行代码”。比如你要一个脚本,把 JSON 歌词转成 srt 字幕,把图片目录拼成视频,这种需求让对话式问答去做会非常累,因为追问和纠错会占用大量轮次。但用 Codex 类工具去生成、修改、运行脚本则顺畅得多。在本文的场景里,它主要负责:
- 搭建目录骨架和模块拆分;
- 生成 JSON 转 SRT 的转换脚本;
- 判断 FFmpeg 命令该怎么组合,并解决跑不通的报错;
- 帮你把重复的“手工操作”固化成函数。
DeepSeek 的优势则在于中文语境理解、续写风格稳定性、长文本处理的性价比。它适合做内容层的工作:给定主题和角色设定,续写歌词、给每一句配画面描述、把字幕断成适合阅读的短句。如果试过用代码模型直接填词,会感觉句子结构太硬,中文音律感明显不行;反过来让写词模型去写 Python 脚本,也容易在工程细节上翻车。两者搭配,是把“过程正确”和“表达自然”分开交给不同模型。
需要说明的是,“Codex”在不同时期的产品形态不太一样,本文不绑定具体版本,只使用它的核心能力定位:面向代码生成与执行的一类模型工具。DeepSeek 也一样,你可以把它替换成任何中文长文本能力强的模型,harness 的框架不会因此改变。
3. 一次 PV 生成包含哪些数据流
在写代码之前,先画清楚数据流。这个项目里,最核心的不是模型,而是“中间产物”。
一次完整的填词 PV 生成,至少要经过下面几层数据转换:
- 配置层:输入角色设定、主题词、BGM 时长、字幕风格、敏感词边界。
- 填词层:DeepSeek 输出结构化的歌词 JSON,包含句子、断句、情绪标记。
- 分镜层:模型根据歌词生成画面描述,按句拆分。
- 时间轴层:根据音频时长估算每句字幕的出现时间。
- 渲染层:FFmpeg 把背景画面、字幕、音频合成最终视频。
- 检查层:人眼确认字幕是否遮脸、节奏是否对。
这里最容易被忽略的是第 4 层。很多新手以为字幕时间码应该由模型生成,但模型并不真的知道音频文件多长。更稳妥的方式是:先读音频总时长,再按歌词句子数量或朗读速度做比例分配,最后人工微调。这样既不会出现模型瞎编时间码,也不会因为音频换了导致整套字幕作废。
数据流设计得好不好,直接决定后面迭代快不快。如果每一步都输出可读的中间文件(lyrics.json、plot.json、timeline.json、subtitle.srt),那么你的每次调整都只需要改输入配置,而不是从头再让模型生成一遍。
4. 环境准备与基础配置
开始动手前,需要准备好运行环境。这个项目并不需要很高的机器配置,核心依赖集中在 Python、FFmpeg 以及两个模型的 API 访问权限。
推荐环境:
- 操作系统:Windows 10/11、macOS、Linux 均可;
- Python 3.10 或更高版本;
- FFmpeg 可执行文件,并且已经加入系统 PATH;
- 支持 OpenAI 兼容接口的大模型服务,本文示例使用 DeepSeek;
- 如果你有 Codex 类 CLI 工具或同类代码模型访问入口,也可以用于生成工程脚本。
如果还不确定自己的模型访问方式,建议先用一个最小脚本验证 API 连通性,再往下搭管线。很多问题的根源其实都出在“以为 API 配好了,实际根本没连上”。
下面是一个简单的目录结构建议:
pv-harness/ ├── configs/ │ └── project.yaml ├── prompts/ │ ├── lyric_writer.md │ └── storyboard.md ├── scripts/ │ ├── llm_client.py │ ├── json_to_srt.py │ └── render_video.py ├── outputs/ │ ├── lyrics.json │ ├── storyboard.json │ ├── subtitle.srt │ └── final.mp4 └── requirements.txt如果你用的是 Codex 类工具,可以让它先帮你构建这个骨架,再逐步填充脚本。如果完全手写,先从下面的 requirements.txt 开始。
openai>=1.0.0 pyyaml>=6.0依赖只要两个,一个是访问模型的 SDK,一个是读取 yaml 配置的库。FFmpeg 不在 Python 依赖里,因为我们是直接调用命令行。
5. 提示词模板与结构化输出设计
很多大模型生成内容不稳定,不是因为模型变笨了,而是提示词既没有规定输出结构,也没有给出边界约束。在这个项目里,填词环节的提示词模板是整个管线的地基。
先看一个可复用的歌词生成模板,存储路径为prompts/lyric_writer.md:
你是一名歌词创作者。请根据下面的主题和风格,生成一段适合演唱的中文歌词。 要求: 1. 必须输出 JSON,不要输出任何解释性文字。 2. JSON 结构固定为: { "title": "歌曲标题", "sentences": [ {"text": "第一句歌词", "emotion": "轻快", "hint": "画面:阳光下的街道"}, {"text": "第二句歌词", "emotion": "坚定", "hint": "画面:傍晚逆光剪影"} ] } 3. 每句歌词控制在 8 到 18 个字。 4. 总句数根据 BGM 时长决定,按平均每句 4 秒估算。 5. 不得包含违规内容,也不得使用任何真实人物的姓名、肖像描述。 主题:{{theme}} 风格:{{style}} BGM 时长:{{duration}} 秒模板里用了{{theme}}、{{style}}、{{duration}}这样的占位符,实际调用时由 Python 替换。这样做的好处是,提示词和代码分离,不同的“特供版”只需要修改配置文件,不需要改代码。
分镜模板prompts/storyboard.md可以设计成接收歌词 JSON,并补充画面描述:
请根据歌词内容生成分镜描述。输入是一个句子列表,输出 JSON。 输出格式: { "shots": [ {"sentence_index": 0, "duration": 4.0, "prompt": "阳光下的街道,人物慢走,镜头跟随"}, {"sentence_index": 1, "duration": 4.5, "prompt": "傍晚逆光剪影,镜头缓慢推近"} ] } 注意:每个 shot 的画面必须与歌词情绪对应,不得出现人物特写和面部可辨识描写。关于“特供版”要补充一点:如果你是为某个固定观众定制内容,一定要在系统提示词里明确“观众偏好”和“内容红线”。比如观众喜欢某种叙事风格,就写进模板;但涉及真实人物姓名、肖像、声音的,都应默认拒绝,因为没有授权风险太高。
6. 核心代码实现:从模型调用到字幕生成
现在进入工程实现。我们分四个模块写,先跑通最小链路,再逐步完善。
6.1 模型调用封装
创建scripts/llm_client.py,统一封装模型请求。这里以 OpenAI 兼容接口调用 DeepSeek 为例:
# 文件路径:scripts/llm_client.py import os import json from openai import OpenAI client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL", "https://api.deepseek.com"), ) def chat_json(system_prompt: str, user_prompt: str, model: str = "deepseek-chat") -> dict: resp = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], temperature=0.8, ) content = resp.choices[0].message.content # 部分模型会输出代码块包裹的 JSON,这里做一次清洗 content = content.strip() if content.startswith("```"): content = content.strip("`") # 去掉开头可能的 json 标记 if content.startswith("json"): content = content[4:] return json.loads(content)这段代码的关键逻辑有几处:
- 从环境变量读取密钥,避免把密钥写进代码仓库;
base_url只写默认地址,实际以你使用的模型服务商为准;- 统一走
chat_json,返回的一定是 dict。如果返回的不是 JSON,说明提示词或服务配置有问题,应该提前暴露而不是吞掉。
6.2 主流程脚本
创建scripts/run_pipeline.py,
# 文件路径:scripts/run_pipeline.py import os import json import subprocess from pathlib import Path from llm_client import chat_json BASE_DIR = Path(__file__).resolve().parent.parent OUTPUT_DIR = BASE_DIR / "outputs" PROMPTS_DIR = BASE_DIR / "prompts" OUTPUT_DIR.mkdir(exist_ok=True) # 1. 读取配置 theme = os.getenv("PV_THEME", "夏日傍晚的奔跑") style = os.getenv("PV_STYLE", "热血、青春") duration = float(os.getenv("PV_DURATION", "90")) # 2. 读取并填充提示词模板 writer_prompt = (PROMPTS_DIR / "lyric_writer.md").read_text(encoding="utf-8") writer_prompt = writer_prompt.replace("{{theme}}", theme) writer_prompt = writer_prompt.replace("{{style}}", style) writer_prompt = writer_prompt.replace("{{duration}}", str(int(duration))) # 3. 生成歌词 lyrics = chat_json( system_prompt="你是歌词创作者,只输出指定 JSON 结构。", user_prompt=writer_prompt, ) with open(OUTPUT_DIR / "lyrics.json", "w", encoding="utf-8") as f: json.dump(lyrics, f, ensure_ascii=False, indent=2) # 4. 生成分镜 storyboard_prompt = (PROMPTS_DIR / "storyboard.md").read_text(encoding="utf-8") storyboard = chat_json( system_prompt="你是分镜师,只输出指定 JSON 结构。", user_prompt=storyboard_prompt + "\n歌词数据:" + json.dumps(lyrics, ensure_ascii=False), ) with open(OUTPUT_DIR / "storyboard.json", "w", encoding="utf-8") as f: json.dump(storyboard, f, ensure_ascii=False, indent=2) print("歌词与分镜生成完成,产物已写入 outputs 目录。")到这里,你已经完成了内容生成部分。所有中间产物都落盘,方便每一次失败时定位问题。
6.3 JSON 转 SRT 字幕
模型输出的歌词 JSON 还不能直接用于 FFmpeg,要先转成字幕文件。创建一个独立的转换脚本scripts/json_to_srt.py:
# 文件路径:scripts/json_to_srt.py import json import sys from pathlib import Path def estimate_duration(lyrics: dict, total_seconds: float) -> list[dict]: """根据音频总时长和句子数量,估算每句字幕的时间轴。""" sentences = lyrics["sentences"] avg_len = total_seconds / len(sentences) timeline = [] cursor = 0.0 for sentence in sentences: text = sentence["text"] # 保守处理:时间轴向右取整到 0.5 秒倍率 start = round(cursor, 2) end = round(cursor + avg_len, 2) timeline.append({"index": len(timeline) + 1, "start": start, "end": end, "text": text}) cursor = end return timeline def format_srt_time(seconds: float) -> str: hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) millis = int((seconds % 1) * 1000) return f"{hours:02}:{minutes:02}:{secs:02},{millis:03}" def to_srt(timeline: list[dict]) -> str: lines = [] for item in timeline: lines.append(str(item["index"])) lines.append(f"{format_srt_time(item['start'])} --> {format_srt_time(item['end'])}") lines.append(item["text"]) lines.append("") return "\n".join(lines) if __name__ == "__main__": lyrics_path = Path(sys.argv[1]) total_seconds = float(sys.argv[2]) output_path = Path(sys.argv[3]) with open(lyrics_path, encoding="utf-8") as f: lyrics_data = json.load(f) timeline = estimate_duration(lyrics_data, total_seconds) with open(output_path, "w", encoding="utf-8") as f: f.write(to_srt(timeline)) print(f"字幕已生成:{output_path}")关于时间轴,这里用的是一个非常朴素的“总时长除以句数”算法。实际项目中,音乐有前奏、间奏、副歌,节奏密度完全不同,直接平均分配只能做初版。更精确的做法是先做音频的人声检测或手动打点,再把时间码交给模型修正,但最小链路里不必一上来就做这么重。
6.4 FFmpeg 合成最终视频
字幕文件生成后,接下来是把背景画面、字幕和音频合成视频。假设你已经有一段背景画面background.mp4和一段音频bgm.m4a,可以用下面这条 FFmpeg 命令:
ffmpeg -y \ -i background.mp4 \ -i bgm.m4a \ -vf "subtitles=outputs/subtitle.srt:force_style='FontName=Noto Sans CJK SC,FontSize=20,PrimaryColour=&HFFFFFF,OutlineColour=&H000000,Outline=2'" \ -c:v libx264 -c:a aac \ -pix_fmt yuv420p \ -shortest \ outputs/final.mp4解释几个参数:
subtitles=outputs/subtitle.srt:加载字幕文件;FontName=Noto Sans CJK SC:指定中文字体,避免中文显示成方框;-shortest:以较短的音频或视频流为终点,适合背景视频比音频长的场景;-pix_fmt yuv420p:保证视频在大多数播放器里正常显示。
在 Windows 上,字幕路径里的反斜杠可能需要转义。如果命令执行后提示找不到字幕文件,优先检查这一点。
7. 运行流程与效果验证
现在把整个流程串起来。假设项目根目录为pv-harness,按下面顺序执行:
cd pv-harness pip install -r requirements.txt export LLM_API_KEY="sk-你的密钥" export PV_THEME="夏日傍晚的奔跑" export PV_STYLE="热血、青春" export PV_DURATION="90" python scripts/run_pipeline.py python scripts/json_to_srt.py outputs/lyrics.json 90 outputs/subtitle.srt执行完毕后,你会在 outputs 目录看到以下几个文件:
outputs/ ├── lyrics.json ├── storyboard.json ├── subtitle.srt └── final.mp4判断是否成功,不能只看“有没有文件”,要看中间结果是否符合预期:
lyrics.json应该是一个标准 JSON,里面有title和sentences数组;storyboard.json的shots数组长度和歌词句子数量一致;subtitle.srt里的时间码是递增的,每段时间在 3 到 6 秒之间;final.mp4能正常播放,中文字幕渲染正常,画面和歌词大致对应。
如果某一步失败,优先看模型返回的原始文本。很多问题并不是代码有 bug,而是模型输出的 JSON 里混进了解释性文字,导致json.loads抛异常。我们的llm_client.py里已经做了一层清理,但只覆盖了最基础的代码块包裹场景,遇到更复杂情况可以打开lyrics.json人工检查。
8. 常见问题与排查方法
这一段是实际使用中踩过最多坑的地方,整理成表格,建议收藏。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
json.loads报错 | 模型返回内容里混入了解释文字或 Markdown 标记 | 打印模型原始输出,查看返回内容 | 增强提示词中的“只输出 JSON”约束,并在代码里做更强的清洗 |
| 中文字幕显示为方框 | 系统中没有对应中文字体 | 查看 FFmpeg 输出的字体警告 | 安装 Noto Sans CJK SC 或指定系统已有中文字体路径 |
| 音画不同步 | 时间轴是平均分配的,无法适配前奏/间奏 | 播放视频对比实际收听 | 手动调整时间码,或先做人声检测再分配 |
| Codex 生成的脚本运行报错 | 依赖版本不一致、路径写死 | 检查报错堆栈和当前目录 | 把脚本设计成可传入路径参数,避免cd /Users/xxx这类绝对路径 |
| API 返回 401 | 密钥或 base_url 配置错误 | 检查环境变量 | 重新确认模型服务地址和密钥权限 |
| 视频合成时字幕不出现 | 字幕文件编码不是 UTF-8,或路径转义错误 | 用文本编辑器检查 srt 文件头部 | 统一使用 UTF-8 写入,并在 FFmpeg 命令里确保路径正确 |
| 歌词内容不够“特供” | 提示词里缺少观众偏好与边界 | 查看生成结果对比配置 | 把风格、语气、禁忌词放进系统提示词 |
其中“字幕不出现”是一个非常隐蔽的问题。很多情况下 SRT 文件本身没错,但 FFmpeg 对 Windows 路径里的冒号和反斜杠非常敏感,需要写成相对路径或把正斜杠转义。
9. 版权、安全与内容边界
这类 AI 内容生成玩法,最不值得踩的就是合规问题。尤其是“特供版”这种自定义倾向很强的项目,你可能会把真实姓名、真实照片、特定声音或未经授权的二创素材放进提示词,整个系统的风险就会迅速上升。
本文所使用的模板默认做了几个约束:
- 不要求模型输出任何真实人物的姓名、肖像、声音特征;
- 分镜描述只写场景和情绪,不写“谁的脸”“哪个人的声音”;
- 所有输入素材必须是拥有使用权的内容。
如果你的确需要制作面向特定人群的定制内容,建议在 prompt 里写“观众偏好”,而不是写“某位真实人物的特征”。前者是对受众口味的一种描述,后者可能涉及人格权授权。同时,生产环境里建议加一层敏感词过滤,模型输出后先过过滤器再进入渲染,而不是把希望全部寄托在模型自觉上。
10. 最佳实践与工程化建议
当最小链路已经跑通后,下面几个改进方向能显著提升维护效率和生产稳定性。
10.1 提示词与代码分离
不要在一段长长的 Python 字符串里写提示词。把提示词放进独立的 md 文件,用占位符做参数替换。这样懂业务的人可以直接改文案,不需要动代码,也不会因为改一个逗号导致整个脚本语法错误。
10.2 所有中间结果落盘
我在上面的示例里,把每一阶段的输出都写进了 outputs 目录。这样你换了一个主题、改了一个风格,不需要重新生成前面的内容,只要重新渲染即可。更重要的是,当一次生成失败时,你能准确知道断在哪一层,而不是每次都要从头跑。
10.3 对模型输出做结构校验
json.loads只是第一步。更稳妥的做法是再写一层校验函数,检查句子数量是否大于零、每个句子的 text 是否非空、时间码是否递增。结构校验失败时,重新调用模型或直接失败退出,比“带着错误数据继续跑”节省大量时间。
10.4 成本与限流
填词模型和代码模型都是按 token 计费的。在调试阶段,尽量使用小批量测试,不要每次都生成完整 90 秒内容。也可以引入结果缓存:同样的主题、风格和模板,生成结果可以直接复用,不用再次调用模型。
10.5 保持背景与音频分离
不要让视频编辑操作依赖单一文件。背景视频、音频、字幕、画面素材各归各的目录,最终渲染时通过配置参数组合。这样换一个音频或背景图,成本只有重新渲染几十秒,不需要重新生成全部内容。
11. 下一步:把单向流水线升级为可交互的工作台
如果你已经跑通了上面的完整链路,其实还只完成了“量产工具”的一半。真正的 harness 还应该支持“人工修正回流”:字幕时间轴不准、某句歌词情绪不对、分镜描述不符合预期时,你修改中间 JSON 后,管线能只生成下游产物,而不是全部重跑。
一个简单实现方式:给run_pipeline.py增加参数,比如--from-lyrics、--from-storyboard,跳过上一步直接执行后续渲染。这样你就拥有了一个类似“所见即所得”的工作台:模型负责批量生成初稿,你负责在关键节点做判断和修正。
“特供版填词 PV”的本质,是让一套固定偏好以最高效率重复出现在内容里。你用 harness 锁定的不是某一个视频,而是一整套稳定产出的能力。把这套能力打磨好,比任何一次偶然的“AI 生成爆款”都更值得投入。