本节摘要:本节给流水线立第二块地基:契约的载体。先回答选型问题——模块间为什么用 JSON 而不是 YAML、CSV 或二进制格式:可读可 diff、语言中立、大模型能可靠输出、标准库直读直写,四个理由在视频场景里全部命中。然后给出三个核心结构:剧本 JSON(场、动作、台词)、分镜 JSON(镜头号、时长、画面描述、台词、运镜、转场)、时间线 JSON(轨道、片段、入出点),三者构成「剧本到分镜到时间线」的推导链,每一步都是可校验、可人工修改的文件。最后讲契约的执行:字段校验代码怎么写,schema_version 字段为什么是长流水线的保险丝。本节的三个 schema 会被第 2、3、5 章直接引用。
中间表示的候选有四种,逐个过:
| 候选 | 可读可 diff | 语言中立 | 模型可输出 | 标准库直读 | 判定 |
|---|---|---|---|---|---|
| JSON | 好 | 好 | 好(结构化输出的默认格式) | 好 | 采用 |
| YAML | 好 | 好 | 一般(缩进易错、多义) | 需第三方库 | 不用 |
| CSV | 差(长文本溢出单元格) | 好 | 一般 | 需处理转义 | 仅表格数据用 |
| 二进制(如 protobuf) | 差(diff 不可读) | 好 | 差 | 需 schema 编译 | 不用 |
JSON 的代价有二:注释没有标准语法(本书用「字段即文档」的方式缓解,字段名自带含义);超长文本(整段台词)会挤掉纵向可读性(用缩进与转义换行缓解)。在「大模型是第一公民」的流水线里,这两处代价远小于收益——结构化输出 API 的默认目标格式就是 JSON,这一条几乎决定了选型。
剧本段把一句想法变成「场」的列表,每场有地点、动作、台词。示例使用原创短剧《修表匠与停摆的钟》:
# script_example.py —— 剧本 JSON 的结构示意(原创示例,文件名 script.v1.json) script = { "schema_version": 1, "title": "修表匠与停摆的钟", "target_duration": 60, "style": "温暖手作感,暖色调,慢节奏", "scenes": [ { "scene_id": 1, "location": "老城钟表铺,傍晚", "action": "老修表匠把一块停摆的怀表凑到台灯下,眯眼端详", "lines": ["这块表,四十年没停过。"], }, { "scene_id": 2, "location": "同一场,深夜", "action": "他拆开后盖,镊子夹起一枚细小的齿轮对着灯光看", "lines": ["不是齿轮坏了,是发条上得太满。"], }, ], }
| 字段 | 类型 | 含义与用途 |
|---|---|---|
| schema_version | 整数 | 结构版本,升级保险丝(见第五节) |
| title、style | 字符串 | 全片标题与风格基调,下传给每一段 |
| target_duration | 数值 | 目标总时长(秒),分镜段的时长预算依据 |
| scenes[].scene_id | 整数 | 场号,改一场不改全片的定位键 |
| scenes[].location | 字符串 | 场景地点,素材与生成段的画面依据 |
| scenes[].action | 字符串 | 场内动作描述,翻译成分镜画面描述的原料 |
| scenes[].lines | 字符串列表 | 台词,音轨与字幕段的输入 |
分镜段把每场切成镜头。它是流水线的信息枢纽:向上承接剧本,向下驱动素材、生成、剪辑三段,也是人工审片的把关点。
# storyboard_example.py —— 分镜 JSON 的结构示意(文件名 storyboard.v1.json) storyboard = { "schema_version": 1, "script_ref": "script.v1.json", "shots": [ { "shot_id": 101, "scene_id": 1, "duration": 4.0, "visual": "钟表铺内景,台灯暖光下,老人双手捧着怀表端详", "line": "这块表,四十年没停过。", "camera": "缓推,近景", "transition": "切", }, { "shot_id": 102, "scene_id": 1, "duration": 3.0, "visual": "特写:怀表表盘,秒纹丝不动", "line": "", "camera": "固定,特写", "transition": "叠化", }, ], }
镜头号采用「场号乘一百加序号」的编码(101、102、201……),好处一眼可见:从镜头号就能反查所属场,改一场时天然聚组。duration 之和要对照剧本的 target_duration 校验;camera 与 transition 是给生成段与剪辑段的指令,第 3.2 节与第 5 章分别展开。
时间线把「镜头序列」翻译成「轨道与片段」,是 ffmpeg 与 moviepy 的直接输入:
# timeline_example.py —— 时间线 JSON 的结构示意(文件名 timeline.v1.json) timeline = { "schema_version": 1, "resolution": [1920, 1080], "fps": 30, "tracks": [ { "track": "video", "items": [ {"shot_id": 101, "clip": "04_clips/shot_101.mp4", "in": 0.0, "out": 4.0}, {"shot_id": 102, "clip": "04_clips/shot_102.mp4", "in": 0.0, "out": 3.0}, ], }, { "track": "subtitle", "items": [ {"start": 0.4, "end": 3.6, "text": "这块表,四十年没停过。"}, ], }, ], }
三个 schema 构成一条推导链,每一步都有明确的检查点:
idea.md ──▶ 剧本 JSON ──▶ 分镜 JSON ──▶ 时间线 JSON (场与台词) (镜头施工图) (轨道与片段) │ │ │ 校验:场号连续 校验:镜头号连续、 校验:片段存在、 台词非空 时长和达标 总长与轨道不越界
契约不校验等于没有。分镜段的校验函数示例(不依赖第三方库,jsonschema 库是规模化后的升级选项):
# validate_storyboard.py —— 分镜 JSON 的手工校验(标准库实现) import json REQUIRED_KEYS = {"shot_id", "scene_id", "duration", "visual", "line", "camera", "transition"} def validate(path): with open(path, encoding="utf-8") as f: data = json.load(f) problems = [] total = 0.0 for shot in data["shots"]: missing = REQUIRED_KEYS - set(shot) if missing: problems.append("镜头 %s 缺字段:%s" % (shot.get("shot_id"), sorted(missing))) if shot.get("duration", 0) <= 0: problems.append("镜头 %s 时长非正" % shot.get("shot_id")) total += shot.get("duration", 0) target = data.get("target_duration") if target and abs(total - target) > 1.5: problems.append("镜头总时长 %.1f 秒偏离目标 %.1f 秒" % (total, target)) return problems
关于 schema_version:流水线活得比任何一次重构都长。字段要加、语义要改时,规则是「只加不改不删」——新增字段给默认值,旧字段保留但可标注废弃,大改则升主版本号并在读取处做迁移函数。所有段在校验失败时的正确行为是:报错并停下,而不是猜一个默认值继续跑——带病产物会在下游放大成不可定位的怪问题。
契约写了,产物落在盘上还要有规矩——下一节给出目录分层、命名版本与配置驱动的可复现性方案。