给 Aegisub 写 lua 脚本来辅助翻译既有字幕
2021年3月31日 - 7025 字

这篇文章本质上是一个流水帐记录,未必有参考价值. 如果参考的话,读者本身需要有一定的编程基础,懂一点点 lua,同时还要对 Aegisub 软件有一点了解.

背景与需求

看了纪录片《波士顿市政厅》(City Hall),因为没有看到这部纪录片有中文字幕,就一直想自己翻译一个出来,帮助其他想看的人.

我手里已经有这部纪录片的英文字幕了,而且时间轴都是对齐的,因此可以直接在原有时间轴上进行翻译. 如果没有本来的字幕,则应该按照大多数字幕组的工作流程,先听写、断句、打轴,然后再翻译. 这篇文章默认已经拥有了时间轴对齐的原文,只剩下翻译的工作要做. 我使用 Aegisub 软件,我的工作环境是 Linux.

为了放便感兴趣的读者动手尝试,我在这里提供一个字幕文件样本,里面只保留了前 20 行的字幕内容,使用它可以完整地操作这篇文章中的所有步骤. 点击这里下载.

思路与流程

打开 Aegisub 软件,点击菜单栏 - “文件” - “打开字幕”,选择手头已经有的英文字幕文件 CityHall.srt. 然后:

  1. 点击菜单栏 - “字幕” - “样式管理器”,在左边样式库下边点击“新建”,“样式名称”填入“English”,然后为英文字幕的样式做一些配置,点击“确定”保存;
  2. 再次在左边样式库下边点击“新建”,“样式名称”填入“中文”,然后为中文字幕的样式做一些配置,点击“确定”保存;
  3. 在左边样式库中分别选中“English”和“中文”,点击“复制到当前脚本->”,把这两种样式应用到当前的工作环境中,然后点击“关闭”关掉样式管理器窗口;
  4. 用鼠标拖拽或使用 Ctrl + A 选中所有字幕,在字幕编辑框里的样式下拉菜单中选择“English”;
  5. 鼠标右键,选择“重复行”,这时选中的字幕应该是新重复出来的那些行;
  6. 在字幕编辑框里的样式下拉菜单中选择“中文”;
  7. 点击菜单栏 - “字幕” - “翻译助手”,对[重复出来的那些、我们选择了“中文”样式的行]进行逐行翻译,每翻译好一句以后按 Enter 键确认并翻译下一行,直到完成;
  8. 英文的字幕里有一些换行符 \N,我们希望把它们替换成空格;
  9. 我们希望把所有字幕按时间进行重新排序,即一行英文一行中文,这样也方便我们查看;
  10. 整体检查一遍,输出字幕文件,整个工作完成.

遇到的问题

这些工作中,1 - 6 条都非常容易,动动鼠标就能完成;第 7 条费点事,但这是我能找到的最方便的翻译字幕的办法了——当然更简单的做法是写个脚本,用 Google 翻译先弄出一个机翻出来,然后再用第 7 条这里的办法订正一遍;第 8 条非常适合使用脚本来做.

第 9 条其实可做可不做,跳过第 9 条,直接输出字幕文件也未尝不可. 但我习惯最后再从头到尾一边播放视频一边检查一遍,检查的时候可能会对字幕再做点细微的更改. 问题就此出现:我们既可能修改某一条英文字幕,又可能修改某一条中文字幕,如果所有英文字幕集中在上边,所有中文字幕集中在下边,那就要经常上下滚动翻找,十分不方便. 因此,第 9 条还是很有必要的.

综上所述,这篇文章要写两个 lua 脚本,解决两个问题:第 8 条和第 9 条.

脚本放在哪里?

打开 Aegisub,点击菜单栏 - “查看” - “选项” - “自动化”,看一下“自动载入路径”:

?user/automation/autoload/|?data/automation/autoload/

这里有两个变量,在我的 Linux 下:

  • 变量 ?user 的含义是:~/.aegisub/
  • 变量 ?data 的含义是:/usr/share/aegisub/

综上所述,我们可以把自己写的脚本放在 ~/.aegisub/automation/autoload/xxx.lua,然后从 Aegisub 那边点击菜单栏 - “自动化” - “自动化” - “重新扫描自动载入文件夹”,这样就能看到我们自己的脚本了.

去掉换行符

我们先来完成第 8 条的任务,把字幕中的换行符 \N 替换成空格. 脚本如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
--[[
说明:
    这个脚本是用来去除字幕中的 \N.
]]

-- 一些描述性的信息
script_name = '去掉换行'
script_description = '把每一行字幕中的 `\\N` 去掉.'
script_author = 'Zero'
script_version = '0.1'

-- 这是我们的函数,第 38 行保证了它会被执行.
function strip(sub, sel)

    -- 对选中行进行遍历
    for _, i in ipairs(sel) do

        -- 取出字幕
        local line = sub[i]

        -- 对字幕进行一些操作
        line.text = string.gsub(line.text, '\\N', ' ')

        -- 把操作好的字幕再放回去
        sub[i] = line
	
    end
    
    -- 设置一个撤销点
    aegisub.set_undo_point(script_name)
    
    -- 保持选中行仍然是选中状态
    return sel

end

-- 为上面的函数进行注册
aegisub.register_macro(script_name, script_description, strip)

具体的解释见下面两个小节.

脚本的结构

1
2
3
4
--[[
说明:
    这个脚本是用来去除字幕中的 \N.
]]
  • 第 1 - 4 行是 lua 的注释语句,我们一般会在脚本的最开始用这样的格式对这个脚本的用途、许可证等问题进行一定的说明;
 6
 7
 8
 9
10
-- 一些描述性的信息
script_name = '去掉换行'
script_description = '把每一行字幕中的 `\\N` 去掉.'
script_author = 'Zero'
script_version = '0.1'
  • 第 7 - 10 行是一些对这个脚本的描述,它们会显示在菜单栏 - “自动化” - “自动化”打开的窗口中,并通过第 38 行函数的第一个参数显示为菜单栏 - “自动化” - “去掉换行”;
37
38
-- 为上面的函数进行注册
aegisub.register_macro(script_name, script_description, strip)
  • 第 38 行除了将脚本名、脚本描述进行注册以外,还注册了一个函数 strip,这个函数就是在 Aegisub 中点击“去掉换行”之后会执行的函数了;
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
-- 这是我们的函数,第 38 行保证了它会被执行.
function strip(sub, sel)

    -- 对选中行进行遍历
    for _, i in ipairs(sel) do

        -- 取出字幕
        local line = sub[i]

        -- 对字幕进行一些操作
        line.text = string.gsub(line.text, '\\N', ' ')

        -- 把操作好的字幕再放回去
        sub[i] = line
	
    end
    
    -- 设置一个撤销点
    aegisub.set_undo_point(script_name)
    
    -- 保持选中行仍然是选中状态
    return sel

end
  • 第 13 - 35 行就是定义的函数 strip 的函数体,它接受了 sub 和 sel 两个参数:
    • sub:第一参数是整个字幕中的所有数据,里面包含的东西非常非常多,我们不需要知道它里面各种数据的组织形式,只需要知道用 sub[num] 能取出第 num 行的字幕就可以了.
    • sel:第二个参数是一个 table,它表示当前用户在 Aegisub 软件里选中的那些行,键就是从 1 开始的递增序列,值则是其对应的行号.
    • 实际上,还有第三个参数是可选的,用来表示当前激活的那一行字幕. 一般情况下都用不太到.
  • 第 16 - 27 行的 for 循环基本上就是很多 lua 脚本的固定套路了,先按行遍历把字幕取出来,然后对字幕进行一些操作,最后再把操作完成后的字幕放回去. 这里一定要参考的是官方文档中的 Dialogue line table 部分,看一看每一行取出来的是什么数据类型,它有哪些可以拿来用的域. 就像这里的 line.text,其实还有 line.class, line.raw, line.section, line.comment, line.layer, line.start_time, line.end_time 等等等等.
  • 第 30 行是设置一个撤销点,这样一来,通过菜单栏 - “自动化” - “去掉换行”运行了这个脚本以后,可以通过菜单栏 - “编辑” - “恢复 去掉换行”来撤销操作. 实际上,从 Aegisub 3.0 版本以后,这句话即便没有写,软件也会自动帮你生成撤销点,但我们还是养成一个良好的习惯,把它写上. 而且通过这句话可以自主指定撤销点的名字.
  • 第 33 行是函数的返回值,返回值应该是一个 table,里面是一些行号,软件则在执行完该脚本后将返回值里面的那些行高亮选中. 像这里,我们就还是返回第 2 个参数 sel,表示那些被选中的行依然保持被选中的状态.

lua 代码中要注意的细节

关于 lua 需要注意的有三点:

  • lua 的变量是区分 global 和 local 的,大多数情况下我们用局部变量就够了,要在变量名称前加上 local.
  • for 循环里的 pairsipairs 是有区别的,前者对索引没有要求,后者要求索引必须是从 1 开始单调递增. 第二个参数 sel 恰好满足后者的条件,所以我们在这里用 ipairs. 这里的 _, i 其实就是键值对,键是从 1 开始单调递增的,在代码中没有什么用,所以我们就用下划线 _ 不要它就行了;值是用户在 Aegisub 里选中的那些行号,用这些行号可以取出第一个参数 sub 里面的字幕.
  • lua 关于字符串的处理需要自行学习掌握,这里使用了 string.gsub,这个还是相当好用的.

将中英文字幕混排

现在还剩第 9 条任务,把所有字幕按时间进行重新排序,即一行英文一行中文. 先分析一下:我们已经有的是上半截英文、下半截中文的字幕,它们的顺序都是正确的,只要把它们穿插起来就行了. 那我们只要先预先判断一下,看看用户选择的行(需要用户提前全选或选择一部分)的总数是不是偶数,如果是偶数的话就把它们从中间一分为二,然后从上半部分取一行、从下半部分取一行,如此循环,也就完成了我们的任务. 代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
--[[
说明:
    把中文的字幕和翻译好的英文字幕穿插在一起. 要求用户所选行数一定要是偶数,
    且一半英文字幕、一半中文字幕,都是按照相同的顺序上下对应起来的.
]]

script_name = '双语穿插'
script_description = '把中文的字幕和翻译好的英文字幕穿插在一起.'
script_author = 'Zero'
script_version = '0.1'

function mix(sub, sel)
    
    local sub_backup = {}
    
    for subi, seli in ipairs(sel) do
        sub_backup[seli] = sub[seli]
    end
    
    local slen = #sel
    
    for subi, seli in ipairs(sel) do
        if subi % 2 ~= 0 then
            -- 索引是奇数,英文,加 1 再除以 2,在 sel 里取出这个索引的值.
            sub[seli] = sub_backup[sel[(subi + 1) / 2]]
        elseif subi % 2 == 0 then
            -- 索引是偶数,中文,加 n 再除以 2,在 sel 里取出这个索引的值.
            sub[seli] = sub_backup[sel[(subi + slen) / 2]]
        end
    end
    
    aegisub.set_undo_point(script_name)
    
    return sel

end

-- 验证函数
function mix_validation(sub, sel)
    return #sel % 2 == 0
end

aegisub.register_macro(script_name, script_description, mix, mix_validation)

具体的解释见下面两个小节.

关于 Aegisub 的验证函数

  • 第 43 行,这里的注册函数和之前的有所不同,还多注册了一个 mix_validation 函数. 这个函数作为注册函数的第 4 个参数,表示软件要预先判断一下目前的状况能不能运行这个脚本.
38
39
40
41
-- 验证函数
function mix_validation(sub, sel)
    return #sel % 2 == 0
end
  • 第 39 - 41 行就是这里注册的验证函数. 这里的验证函数很简单,就是看一下当前用户选择的行的总数能否被 2 整除,如果可以就返回 true,如果不行就返回 false. 体现在软件的操作上就是:如果你选择的行的数量是偶数,那么在菜单栏 - “自动化” 里就能点击“双语穿插”来运行这个脚本;如果你选择的行的数量是奇数,那么在菜单栏 - “自动化”里,你会看到灰色的“双语穿插”,不能点击,表示目前这种情况下你不能运行这个脚本.
  • 第 12 - 36 行就是这个脚本的核心,比起之前的脚本,这个要稍稍复杂一点点. 我们在下一小节稍稍做点解释.

lua 代码中要注意的细节

20
21
22
23
24
25
26
27
28
29
30
local slen = #sel
    
for subi, seli in ipairs(sel) do
    if subi % 2 ~= 0 then
        -- 索引是奇数,英文,加 1 再除以 2,在 sel 里取出这个索引的值.
        sub[seli] = sub_backup[sel[(subi + 1) / 2]]
    elseif subi % 2 == 0 then
        -- 索引是偶数,中文,加 n 再除以 2,在 sel 里取出这个索引的值.
        sub[seli] = sub_backup[sel[(subi + slen) / 2]]
    end
end

第 20 - 30 行的代码和注释已经足够清楚了,这就是完成穿插的核心代码,主要是数学上的逻辑:判断行号是奇数还是偶数,如果是奇数就从上半部分英文的部分选一行放进来,如果是偶数就从下半部份中文的部分选一行放进来.

第 14 - 18 行的操作值得一说.

14
15
16
17
18
local sub_backup = {}
    
for subi, seli in ipairs(sel) do
    sub_backup[seli] = sub[seli]
end

14 - 18 行做的是把用户选中的那些字幕复制一份出来作为备份,这样在 sub 这个变量上随心所欲地做一些改动就不会影响到备份出来的原始数据了. 这里的关键在于,lua 用 = 传递 table 或 userdata 的本质是引用,对其中一个做更改会影响到另一个,所以我们必须自己手动把它们复制出来. 举个例子:

1
2
3
4
5
6
7
8
old = {1, 2, 3}
new = old
print(old[1], old[2], old[3]) -- output:1 2 3
print(new[1], new[2], new[3]) -- output:1 2 3
old[1] = 'interesting'        --对旧的变量做一些更改
print(old[1], old[2], old[3]) -- output:interesting 2 3
print(new[1], new[2], new[3]) -- output:interesting 2 3
                              --导致新的变量也同步了那个更改

实际上,这样的语言有很多,大部分引用语义的语言都是传引用的. 比如 java, javascript, python. 一个 python 的例子如下:

1
2
3
4
5
6
7
old = [1, 2, 3]
new = old
print(old) # output:[1, 2, 3]
print(new) # output:[1, 2, 3]
old[0] = 'interesting'
print(old) # output:['interesting', 2, 3]
print(new) # output:['interesting', 2, 3]

实话说,我以为我对 python 还算是熟悉,但我真的第一次知道这件事. 好像之前没有遇到过这种操作吧?总之这是写这份脚本时遇到的一个小小的坑.

其他

有了上面两个脚本的帮助,我们就能快速把 1 - 9 完成了. 在输出字幕之前,我一般还会做点微小的调整:点击菜单栏 - “计时” - “时间后续处理器”,关闭“开始提前”,保留“结束延后”,并设置结束延后的时长为 500 ms,然后启用相邻字幕行连续的功能. 这是为了使字幕在台词说完以后还能再显示半秒,给观众一个更舒适的时间来阅读字幕;另外对于两行时间非常接近的字幕,就让第二行开始的时间作为第一行结束的时间,不会看起来一闪一闪的. 这个技巧来自 b 站教程:快速打轴法.

至此,所有任务完成,我感受到了自己写脚本的强大之处. 总的来说,Aegisub 的 lua 脚本并不难写,思路理解起来也非常自然,以后在做字幕的时候遇到任何麻烦的事情就都可以用脚本来做了,这次的学习性价比极高!

补充一个翻译视频内说明文字的流程:

  1. 在下面字幕区所有字幕的最后一行右键,选择“插入(之后)”;
  2. 在上面的字幕编辑区把样式改成“Default”;
  3. 内容写:{\fnSerif\fs72\b1\pos(100,100)}示例文本
  4. 把定位的方式改成“帧(R)”而不是“时间(I)”,用帧来定位比较好记,是一个整数;
  5. 把开始时间和结束时间都设置为 0;
  6. 在下面字幕区右键这一行字幕,选择“重复行”,反复这样多做几次,重复出足够的行出来,作为所有视频内文字说明的初始状态空模板;
  7. 在视频预览区开始从头到尾播放视频,遇到需要翻译的视频内的说明文字就暂停一下,记录一下初始和结束时刻的帧数;
  8. 找一个初始状态空模板字幕,点击一下(视频会跳到第 0 帧但是没关系),在上面的字幕编辑区把“开始时间”和“结束时间”改成刚刚记下的帧数;
  9. 确保视频预览区下方“自动将视频移至所选字幕的开始时间”是选中的状态,然后在下面的字幕区点击这条字幕,这时视频就会自动跳转到刚刚输入的开始时间的帧数;
  10. 播放视频,一来再次确认一下开始时间和结束时间是否需要做些微调,二来阅读视频内的说明文字,准备翻译;
  11. 在上面的字幕编辑区完成字幕的翻译,然后在视频预览区左边,点击第二个按钮,是一个十字形的“拖放字幕(R)”,可以看到字幕的中心出现了一个红色的拖拽标示,用鼠标把字幕拖拽到合适的地方,这个时候字幕编辑区里面的\pos(100,100)会自动改变;
  12. 完成后继续播放视频,重复 7-12 步骤中的操作.

更新于 2022 年 4 月 6 日.

参考资料

我的学习主要参考了 Lyger, Creating Lua Automation Scripts for Aegisub——这篇文章是一篇正儿八经的教程,而且它前面还包含了 lua 语言的基础知识,推荐给大家,值得参考.

另外 Aegisub 的官方文档也是非常值得参考的,上面推荐的文章也多次引用. 可惜的是,Aegisub 软件已经很长时间没有更新,其官网也缺乏维护,因此无法找到在线的官方文档了,只能是 Google 搜索后下载离线的 PDF 格式文档以备查阅. 还有一篇中文的学习笔记,在这里一并列出:Aegisub32 Lua Script 学习.

其他人写的 lua 脚本:

把中英文的字幕分开

我们在上面写了脚本实现了中英文字幕的混排. 很自然地,我们也会想做一个逆操作,把已经混排的双语字幕分开. 这已经非常简单了,只需要在上面的脚本中简单改几个地方即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
--[[
说明:
    把穿插在一起的中英文字幕分开. 要求用户所选行数一定要是偶数,
    且每行英文字幕相邻的下面一行都是其对应的中文字幕.
]]

script_name = '分开穿插的双语'
script_description = '把穿插在一起的中英文字幕分开.'
script_author = 'Zero'
script_version = '0.1'

function seperate(sub, sel)
    local sub_backup = {}
    for subi, seli in ipairs(sel) do
        sub_backup[seli] = sub[seli]
    end
    local slen = #sel
    for subi, seli in ipairs(sel) do
        if subi <= slen / 2 then
            sub[seli] = sub_backup[sel[subi * 2 - 1]]
        elseif subi > slen / 2 then
            sub[seli] = sub_backup[sel[subi * 2 - slen]]
        end
    end
    aegisub.set_undo_point(script_name)
    return sel
end

function seperate_validation(sub, sel)
    return #sel % 2 == 0
end

aegisub.register_macro(script_name, script_description, seperate, seperate_validation)

有了我写的这两个混排和分开的脚本,再加上上一小节参考资料中提到的 syler 写的把中英文字幕分成两行的脚本,这三个工具在处理双语字幕时已经非常游刃有余了.

机翻脚本

最后离题记录一下第 7 条关于自动机翻的问题. 我本来想的是,把自动机翻也写成一个 lua 脚本,但我对 lua 的网络库不太熟悉,找了半天也没找到现成的代码,感觉自己写起来会比较费劲,尤其是那个 Google 翻译的 token key 问题,看着就比较棘手. 所以我就转头寻找起了 python 的现成的翻译库:

  • googletrans:项目主页源代码. 这个项目相当于是用非正常手段(aka 爬虫)获得免费翻译的使用,而且它有个优势是可以指定 Google 翻译的服务器地址,因为中国大陆这边访问 translate.google.com 受到限制,但可以访问 translate.google.cn. 不过我尝试的时候遇到了问题,没有很好的解决办法.
  • deep-translator:项目主页源代码. 这个项目就没法指定 Google 翻译的服务器地址了;它的优势是翻译服务众多:microsoft、Pons、Linguee、Mymemory、Yandex、QCRI、DeepL. 我猜想它们的英译中可能还不如百度翻译,所以没有尝试. DeepL 口碑不错,想试来着,但它需要注册 DeepL 帐号后用自己的 key 去使用官方接口. 可惜 DeepL 不支持中国地区的注册,遂作罢.
  • 还有很多很多类似的 python 库,但时间有点太久远了,都两三年了,也都没有尝试.
  • 最后找到一篇文章,机翻字幕文件 - 字幕组机翻译小助手,虽然是 2018 年的了,不过还是很有借鉴意义. 它让我意识到,各家翻译服务虽然在网页版上是免费的,但没有可以免费调用的官方 api. 要么就写代码和人家的反爬机制做斗争,要不就乖乖交钱使用官方提供的稳定 api. 百度倒是有免费的服务,但也是得注册,而且每秒只能调用 1 次. 该文章作者写了一个软件,可以辅助我们(在注册交钱拥有自己的 key 之后)使用各类翻译服务直接翻译字幕软件. 那个软件不支持 Linux,故没有尝试.

结果

其实这篇文章早就写好了,我本来计划把电影字幕翻完之后一起发出来的,但《波士顿市政厅》那个电影太长了,我又很拖延,到 2021 年 3 月底我才翻了半个小时的内容;而且网上找的那个英文字幕问题不少,我还得常常做点英文的听写改正一下……结果就有字幕组接手翻完了:orange 字幕组 | 波士顿市政厅. 因此把这篇文章直接发出了!