用 AI 写 ASS 特效字幕: 一套可照做的规格
Note
我想让 AI 替我写出本地那批成品同级的效果歌词. 试过之后发现卡点不在"AI 会不会写 ASS", 而在我能不能把要求写成它可执行、又可判定的形式. 下面是把这件事定死的那套规矩.
0x00 这套规格给谁用
给一首日语歌做特效歌词、并要嵌进网页播放器的项目. 交付物是一份 ASS 文件, 渲染在固定画布上.
不适用的情况: 只要静态歌词 (那就别用 ASS, 用普通字幕格式); 或者画布尺寸不固定、允许播放器随时改窗口比例.
先明确一件事: 这类字幕不是"一句一句写"出来的.
一份 3 分钟的歌, 成品通常有数千到数万条事件行. 实测本地 13 份成品的规模: 点兔 71596 行、天使の3P INNOCENT BLUE 34432 行、东方 30107 行、Charlotte 5281 行、Made In Abyss 2012 行.
这些行不是人手敲的. 流程是打轴、写模板、展开三步: 先对歌词打逐字时间, 再写少量模板, 由展开器把模板套到每个音节上. 绚烂的来源是同一组标签被程序化地重复几万遍.
所以"怎么写 ASS"这个问题, 实际是"怎么写那个会被展开的模板".
0x01 契约层: 换了就不成立的东西
一、渲染器决定你能用什么
先查清楚目标渲染器支持哪些标签, 这一步不能省.
标准 libass (ffmpeg 的 subtitles 滤镜) 支持 \p 绘图、\t、\move、\clip、\fad、\blur、\frx/\fry/\frz、\fscx/\fscy.
VSFilterMod 的扩展标签 \1img..\4img (图片纹理)、\fsc、\fsvp、\moves3/\moves4 (贝塞尔移动) 在标准 libass 上不生效.
后果很实际: 同一份 ASS 在 ffmpeg 命令行与在定制播放器里观感不同, 依赖扩展标签的部分无法在命令行验证. 实测点兔那份用了 \fsvp 1016 次.
判据: 写完后用目标渲染器出一帧, 再用手边的另一个渲染器出一帧, 像素级对不上就说明用到了扩展标签.
二、三类行必须注释掉
模板展开会产生四类行: code (定义变量与函数)、template (模板本体)、karaoke (打了逐字时间的原始歌词)、FX (展开结果).
只有 FX 行参与渲染. 前三类不打注释时, 模板不会展开.
判据: 展开后统计 FX 行数, 若为 0 就去检查那三类行有没有注释.
三、时间基准有两套, 不能混
行变量 l.start / l.end 以视频零点为基准. 音节变量 s.start / s.end 以本行开始为基准, 所以行内第一个音节的 s.start 是 0.
标签里的时间参数是相对本行开始的. 想表达"从头到尾"要写 \t(0, l.dur, ...), 写 \t(l.start, l.end, ...) 会把绝对时间当成相对偏移, 效果错位.
k 值单位是厘秒, 其余时间参数都是毫秒. s.dur / l.dur 与 k 同制, 是厘秒.
判据: 单句先渲染, 看动画是否在行首就开始. 若整段静止、只在某处闪一下, 就是基准写错了.
四、绘图模板必须同时加两个修饰语
notext 丢弃被替换的文本. 缺它时绘图行末尾会被拼上原文本; 原文本是数字时, 这些数字会被当成绘图坐标的延续, 图形随即变形 (实测末点坐标 -44 被拉成 -442).
noblank 跳过空白音节. 缺它时绘图模板会在空白音节上画出孤立图形.
判据: 找一个空音节, 看那里有没有多出图形.
五、位置必须落在一个固定矩形内
这是本文档存在的主要理由.
网页播放器把 1920x1080 的画布裁进一个固定尺寸的悬浮窗. 若裁剪窗口按逐帧内容包围盒算, 文字一动框就动, 缩放系数跟着变, 观感上就是"轻微偏移震荡".
实测这份抖动: 相邻关键点的窗口跳变中位只有 1 到 5 像素, 但每首都有 0.6% 到 13.2% 的关键点跳变超过 50 像素.
修法不在渲染侧, 在创作侧: 让所有特效从一开始就画在同一个矩形里, 裁剪于是退化成单个常量.
判据: 逐帧渲染求内容包围盒并集, 落在声明的安全区内, 且四个方向的越界量都是 0.
拿本地 13 份成品去验证这条会失败: 它们的内容并集是 x[0,1875] y[0,1080], 等于整幅画布; 拿四个候选固定框回测, 逐帧完全落入率只有 0.0% 到 6.2%. 所以这条只能对新作品提.
import numpy as np
from PIL import Image
def union_box(frames, bg, thr=12):
"""逐帧与背景做通道差, 求内容包围盒的并集。"""
U = [10**9, 10**9, -10**9, -10**9]
for f in frames:
im = np.asarray(Image.open(f).convert("RGB")).astype(np.int16)
m = np.abs(im - bg).sum(2) > thr
if not m.any():
continue
ys, xs = np.where(m)
U = [min(U[0], xs.min()), min(U[1], ys.min()),
max(U[2], xs.max()), max(U[3], ys.max())]
return U
def overflow(U, safe):
"""四个方向的越界量之和。判据是它等于 0。"""
x0, y0, x1, y1 = safe
return (max(0, x0 - U[0]) + max(0, U[2] - x1)
+ max(0, y0 - U[1]) + max(0, U[3] - y1))
0x02 约定层: 已经替你选好的
一、安全区怎么定
不要一次调准, 用两遍法.
第一遍按设计意图给宽裕值, 生成后逐帧渲染量出内容并集. 第二遍取并集加少量余量 (水平 16px、垂直 12px) 作为正式安全区, 重新生成.
实测收紧效果: 安全区从 1280x240 收到 1155x167, 面积降到 63%, 而裁剪缩放系数从 0.3906 升到 0.4329, 字号视觉大小提升 10.8%, 两次都零越界.
为什么必须两遍: 字号自适应、字形宽度估算、字体的真实 advance, 这三者无法精确一致, 逐帧实测是唯一可靠的收敛手段.
二、三层文字怎么排
日语歌词需要三层同时间轴. 实测成品的位置关系:
日文主行在基线, 字号 60. 假名注音在它上方 36px, 字号是主字的 0.42 倍. 中文对照在下方 48px.
注音是词级而不是逐字: 它覆盖的是"私"这样的整个词, 且与主字共用同一 x 区间.
三、整行先出现, 再逐字上特效
这是硬要求. 歌词首先是给人跟着唱用的, 不能让字一个个从无到有冒出来.
做法: 让每个字的事件时长等于整行时长, 整行同时起止; 逐字点亮靠 \t 的起始时间错峰.
四、用哪一组标签
实测两份成品的标签族高度收敛, 共性只有七类: 锚点、绝对定位、淡入淡出、模糊发光、动态插值、遮罩、绘图.
不要为了"看起来丰富"去堆标签种类. 实测 12 份成品的绘图几何种类数从 2 到 2608 都能成立 (Charlotte 只用 2 种, 点兔 2608 种), 这个数字没有区分能力.
决定观感的是事件预算花在哪一层: 极简派如 Made In Abyss 1960 个事件、绘图 0%; 粒子派如东方 42872 个事件、绘图 97%; 点阵描边派如点兔 71462 个事件、绘图 98.5%.
五、丰富感来自段落级强度包络
这条替代了"多准备几种母题".
实测一份 42872 事件的成品: 每次爆发撒多少粒子取 7 个档位之一, 按段落整段不变, 与句长字数完全无关. 它的包络是 60、110、50、70、90、130、150.
其中 110 掉到 50 那个骤降, 正落在歌词最内省的段落. 重复的是笔画, 变化的是气力.
六、时间尺度要分层
一个字被唱到的瞬间, 同时触发四五个效果, 各自生命周期必须不同. 实测成品的分配:
幽灵层等于整行时长, 白闪 500ms, 星 400ms, 环 619 到 1708ms, 粒子 800ms.
更精妙的是环的参数随字在句中的位置递变: 越靠后的字环存活越久, 终态越大. 这比换形状更能消除机械感.
0x03 偏好层: 写法习惯
\t 的加速度参数大于 1 时先慢后快, 在 0 到 1 之间时先快后慢. 该参数后面的逗号不能省, 漏掉时加速度静默失效.
\move 只能做线性匀速位移. 要变速就把它拆成多个 \t 区间, 或按帧展开.
\org 一旦设定, 旋转中心在整条行内固定; 不设定则旋转中心跟随文字平移. 想让文字绕自己转就设它.
大范围缩放改 \p 的级数, 小幅微调用 \fscx / \fscy. \p 按长度二分: \p2 的长度是原图一半, \p3 是四分之一. \p0 关闭绘图模式.
内联变量的值在替换阶段就写死, 后续无法修改. 需要"记住一个随机数并在同一行的多个音节里复用"时改用 remember / recall, 或 table.
随机数分三层: 无参 math.random() 是 [0,1) 均匀分布, 单参是 1 到 N 的整数, 双参是闭区间整数.
在 code 行调用自写函数必须加反斜杠 g 前缀, Aegisub 自带函数与 Lua 标准库不需要.
Yutils.shape.to_pixels 会把 ASS 体积从几 KB 推到几 MB, 不适合常规特效.
所需的数学绝大多数只需要高中程度.
0x04 可直接拷走的件
kfx-sample.py 是最小可跑样例: 282 条事件, 含三层文字、整行先出现再逐字点亮、固定安全区. 它演示的要素全在, 但只有两层包装.
kfx-boundary-check.py 是边界检查: 逐帧测内容包围盒, 判据是四个方向的越界量都是 0. 拿它自己的输出帧实测 (110 帧), 结果是并集 x[590,1301] y[758,959], 越界帧 0.
跑法: python3 kfx-sample.py > out.ass, 再抽帧喂给边界检查.
0x05 让 AI 做这一整套时, 判据本身也要被验证
上面那些规矩我做成了一组可跑的判据. 跑通之后有个意外收获: 我定的判据本身出过错, 而且是它自己暴露的.
我原先写了一条"同一首歌内图元种类下限". 拿 12 份真实成品一测, 它判掉了 8 份生产级作品. 后来发现几何种类数的分布是 2 到 2608, 这个量根本没有区分能力.
现在的规矩是: 任何门禁判据必须先在一批真实成品上跑过, 确认它有区分能力, 再写进门禁. 拍脑袋定阈值会稳定地误杀好作品.
另一条同样来自踩坑: 生成的东西"看起来对"与"结构对"是两件事.
实测有成品带着 486 条未求值的模板占位符和零负时长事件, 它们渲染出来是 0 像素, 却照样占着事件数. 所以统计手法规模之前必须先跑一遍静默失效检查.
把这套规矩固化的价值在于: 它让"写得对不对"从审美判断变成了可执行、会失败的检查.
本文没有引用站内其它文档。

