介绍
一部短片删去了两秒钟的片头,但字幕仍沿用原来的时间轴。第一个绿色场景现在从第 2 秒开始,对应字幕却从第 4 秒才开始。画面中的场景卡会显示时间,你不需要靠听声音猜测。
你将用整数毫秒表示字幕时间,并实现一个 Rust 转换过程,偏移每个字幕条目,同时保留完整的 Unicode 和多行文本。随后,你会把实际导出的文件加载为浏览器视频的字幕轨道。下一实验将重点讲解受保护的输出写入边界和常见失败的恢复方法。影片和字幕是本课程原创的合成测试素材,并非真实事件的录像。
你应当已经了解 Rust 结构体、枚举、向量、Result、字符串、文件操作,以及基本测试和循环。完成现有的 Rust CLI 课程会有帮助,但并非必需;本实验已提供参数解析,不要求掌握 clap。作者估计本实验需 45 分钟,包含阅读和常见错误的纠正;这不是初学者实测的完成时间。
查看对齐效果并建模毫秒时间
本步骤先播放一个已知正确的字幕样例,查看它与原时间轴的关系,然后实现数值时间转换。
打开 Preview 标签页。预备播放器最初选择的是 data/edited.webm 和 data/reference.vtt。参考文件是已经可以正常工作的示例,并非你提交的输出。点击 Load actual files,再点击视频上的 Play。你应当看到绿色场景卡,以及以 Café 林地 🌿 开头的两行字幕。暂停视频。在 Time (seconds) 中输入 2.5,点击 Go to time,直接查看这个场景。该控件会定位真实视频,字幕由视频实际加载的字幕轨道显示。
剪辑后的影片删去了 2000 ms 的片头。原来的第一个字幕条目为 4000–7000 ms,对齐后必须为 2000–5000 ms。对两个端点都应用 -2000,可使字幕提前且不改变持续时间。正偏移量则会使字幕延后。之后,4.5 秒处的蓝色场景将显示有意保留的重叠,60.5 秒处的琥珀色场景将检验跨分钟的情况。这些观察点是公开的,不存在需要听声音破解的谜题。
切换到 Terminal。项目位于 /home/labex/project/subtitle-sync。预备环境包含 Rust 1.99.1。将 Cargo 的目录加入当前 shell 的命令搜索路径,并为本次会话选择可用的 UTF-8 locale:
export PATH=/home/labex/.cargo/bin:$PATH
export LANG=C.UTF-8 LC_ALL=C.UTF-8
$PATH 保留已有的搜索目录。这些导出设置仅在当前终端会话中有效,不会修改 shell 启动文件;每次打开新终端都需重新执行。终端字体仍可能把部分 Unicode 字符显示为占位符。这里应以实际保存的字节和真实浏览器字幕作为可靠的文本检查依据。
进入项目目录。后续所有命令都在此目录执行;如果不确定自己当前的位置,请重新执行该命令:
cd /home/labex/project/subtitle-sync
预备项目将职责分开。src/cues.rs 定义 Cue,验证本课程受控的 WebVTT 格式,提取数值时间字段并格式化输出。src/main.rs 解析命令,并显示成功信息或可恢复的错误。src/export.rs 读取实际指定的输入,在整个转换完成后创建新的输出。下一实验将讲解这个文件边界。你只需编辑 src/time.rs,保留公开的函数名和签名。保持所有其他源文件、Cargo.toml、rust-toolchain.toml,以及 data/ 下的每个原始文件不变。允许采用功能等价的内部实现。未修改的预备文件须保留字节、权限、所有者/组和修改时间;普通读取可能更新访问时间,访问时间不属于此保留要求。
一个字幕条目包含开始时间、结束时间,以及一行或多行文本。用于诊断的 line 记录原始时间行的位置,并非字幕文本的一部分。提供的结构体如下:
pub struct Cue {
pub start: i64,
pub end: i64,
pub text: String,
pub line: usize,
}
i64 是有符号 64 位整数。我们保存整数毫秒,因此跨分钟只需进行整数运算,无需修改时间戳中的标点。开始和结束时间非负,结束时间晚于开始时间;解析器会验证这些关系。String 拥有完整文本,其 Unicode 字符并非都只占一个字节。
使用 cat 查看真实的原始字幕;它只读取文件,不修改文件:
cat data/original.vtt
WEBVTT
00:00:04.000 --> 00:00:07.000
Café 林地 🌿
Second line intact
00:00:06.000 --> 00:00:09.000
Overlap stays
00:01:01.750 --> 00:01:04.250
Across a minute
本课程有意只支持一个较小的纯文本 WebVTT 子集:
- 第一行必须恰好是
WEBVTT,后面跟一个空行。本课程不支持头部元数据和字节顺序标记。 - 每个时间行的格式为
HH:MM:SS.mmm --> HH:MM:SS.mmm,箭头两侧各恰好一个空格。所有数字都是 ASCII 数字。小时至少两位十进制数字,分钟和秒恰好两位且范围为 00–59,毫秒恰好三位。每个完整时间戳都必须能够用非负的 i64 毫秒值表示。 - 按文件顺序,字幕开始时间不能递减;相同开始时间和区间重叠都是合法的。结束时间必须大于开始时间。程序保留文件中的顺序。
- 每个条目至少有一行非空的逻辑字幕文本。Unicode、制表符、首尾空格和多行文本都应保留。即使内容只有空格或制表符也允许,只是看起来可能为空白。真正的空行会结束一个条目,不能表示条目内部的空白文本行。该边界后的文本必须开始另一个有效条目,不能是没有时间行的孤立续行。
- 输入允许 LF 和 CRLF 换行、末尾有或没有换行,以及多个空行分隔条目。预备输出格式使用 LF;0–99 小时写两位小时数字,更大的小时值只增加必要的位数;头部后有一个空行,每个条目后也有一个空行,包括最后一个条目。输出会规范化时间戳的写法和分隔行数量,但不改变逻辑字幕文本。不支持单独的回车符。
- 不支持条目 ID、设置、NOTE、STYLE、REGION、标记、字符引用,以及字幕文本中的
<、>或&。除制表符外的其他控制字符也不支持,包括 NUL。程序会拒绝不受支持的内容,而非悄悄删除它们。
这些限制定义的是本课程的格式,并非完整的 WebVTT 标准。预备解析器负责结构和文本规则,不会替你完成尚未实现的毫秒转换。其辅助函数 time_fields 检查时间戳写法和字段范围,并将 [hours, minutes, seconds, milliseconds] 作为四个非负的 i64 值返回。你的 milliseconds 函数接收的是这些已经验证的字段,而非任意负数或越界分钟值;但组合字段时仍必须拒绝溢出。
第一版实现按照各字段的单位比例进行转换。数组 scales 保存每小时、每分钟、每秒以及每毫秒对应的毫秒数。范围 0..4 包含 0 至 3;相同的数组下标将字段与对应的比例配对。下划线让较大的数字字面量更容易阅读。0_i64 明确指定累加器的类型。
checked_mul 和 checked_add 在结果可表示时返回 Some(value),在整数溢出时返回 None。ok_or 将这个 Option 转换为带有自有错误消息的 Result。? 遇到成功值就继续,遇到错误就立即返回。溢出应当作为普通输入错误被拒绝,而非导致 panic 或时间值绕回。
编写代码前,预测 00:01:01.750 的总值:每个非零字段贡献多少毫秒?写下预期整数,再用下面的运行验证预测。
先阅读实际起始代码:
cat src/time.rs
use crate::Cue 从本库中导入已准备的类型。保留这一行。文件有三个函数;现在只编辑 milliseconds。它的起始 { 位于签名行,配对的结束 } 紧邻 shift_one 之前。本阶段两个偏移函数都必须保留未完成的函数体。
编辑前,仅为允许修改的源码建立检查点。mktemp -d 用唯一后缀替换六个 X 字符来创建目录;已有名称会被拒绝。$() 将该路径存入当前 shell 会话的变量。引号使路径保持为一个整体,&& 只在目录创建成功后执行复制。cp -p 复制文件并保留权限和修改时间。这是源码检查点,不是输出字幕的备份。打印变量并记下实际名称;不要猜测后缀。
rs01_before_milliseconds=$(mktemp -d .edit-before-milliseconds.XXXXXX) && cp -p src/time.rs "$rs01_before_milliseconds/time.rs"
printf '%s\n' "$rs01_before_milliseconds"
printf 打印此目录名,再换行。用 nano 打开已有源码;-c 显示光标位置:
nano -c src/time.rs
按 Ctrl+W,输入 pub fn milliseconds,再按 Enter 搜索。方向键可在文件内移动。在签名中删除 _parts 的前导下划线,让函数体能够使用 parts;保持 [i64; 4] 和返回类型不变。只将该函数已有花括号内的 Err(...) 行替换为转换逻辑。不要删除下一个函数。
按照已验证字段的顺序建立四个比例。可变 i64 总值从零开始。对 0..4 的每个下标,将字段与比例进行带溢出检查的乘法,再将这一部分以带溢出检查的加法加入总值。用 ok_or(String::from("time integer overflow"))? 将任一溢出转换为错误。例如,循环的第一部分计算单项贡献:
let piece = parts[i]
.checked_mul(scales[i])
.ok_or(String::from("time integer overflow"))?;
在循环前使用 let scales = [3_600_000, 60_000, 1_000, 1]; 和 let mut total = 0_i64;。加法按同样的受检模式使用 total.checked_add(piece)。循环后,以 Ok(total) 作为不带分号的最终表达式返回。其余两个占位函数保持不动。
按 Ctrl+O 写入,Enter 确认原文件名,再按 Ctrl+X 退出。如果 nano 在退出时询问是否保存,回答 Y,再确认文件名。运行前回读保存内容:
cat src/time.rs
运行一次转换。cargo run 编译并启动已准备的程序,--quiet 隐藏 Cargo 进度,-- 将后续参数传给程序。程序的 time 命令使用你的转换:
cargo run --quiet -- time 00:01:01.750
00:01:01.750 = 61750 ms
60 秒加 1 秒,再加 750 毫秒,得到 61750。现在查看全部原始条目的值;inspect 读取实际指定的文件并调用解析器:
cargo run --quiet -- inspect data/original.vtt
Cue 1: 4000 --> 7000 ms
Café 林地 🌿
Second line intact
Cue 2: 6000 --> 9000 ms
Overlap stays
Cue 3: 61750 --> 64250 ms
Across a minute
三个记录及第一个条目的两行文本都完整保留。将转换结果与预测比较。
如果误编辑尚未保存,按 Ctrl+X,在保存问题处按 N,即可保持文件不变。对于已保存的错误,阅读 Cargo 指出的文件名和行号,重新打开该源码,修正局部表达式。也可以恢复自己的检查点。milliseconds 检查点保存的是本阶段开始前的状态;恢复后,必须重新实现本阶段及其后的函数。
如果打开了新的 Terminal,原有变量和工作目录不会随之保留。先重复可见的环境和目录命令:
export PATH=/home/labex/.cargo/bin:$PATH
export LANG=C.UTF-8 LC_ALL=C.UTF-8
cd /home/labex/project/subtitle-sync
以下恢复命令是有条件的:仅在撤销已保存的错误时使用。ls -d 列出目录名称本身;* 匹配生成的后缀。如果没有检查点,阅读错误并修复源码,不要编造路径。
ls -d .edit-before-milliseconds.*
read 等待一行输入,并将它存入会话变量。运行后,输入或粘贴上面打印的一个实际完整目录名,再按 Enter。不要输入示例后缀或美元符号:
read rs01_restore
恢复前,确认文件确实是预期的较早源码:
cat "$rs01_restore/time.rs"
cp -p "$rs01_restore/time.rs" src/time.rs
然后重新打开 nano,重做缺少的实现,再运行先前失败的命令。不要重跑 setup 或修改受保护的输入文件。修复代码不会替换已有输出 VTT,也不会替换浏览器 Blob 中已加载的文件;重新生成要求的路径前先保留旧结果,然后选择该实际输出,再次点击 Load actual files。
偏移整个字幕集合
本步骤将先偏移一个有明确类型的字幕条目,再将该操作应用于所有条目,并观看实际保存的输出。继续在 Terminal 中操作,工作目录为 /home/labex/project/subtitle-sync。
shift_one 通过 &Cue 借用一个经过解析器验证的条目;它必须返回新条目,而不改变原条目。使用 checked_add 分别将有符号偏移量加到两个端点,再拒绝负数结果。不要把负值转为无符号整数、取绝对值或截断为零;这些捷径都会改变要求的时间。
返回的结构体保存新的时间、克隆完整的自有文本,并复制诊断行号。.clone() 保留整个 String,包括 Unicode 和每一行文本。结构体字段的简写 start, end 会使用同名变量。|| 表示只要任一端点为负,就应拒绝此次偏移。
编辑前,仅为允许修改的源码建立检查点。mktemp -d 用唯一后缀替换六个 X 字符来创建目录;已有名称会被拒绝。$() 将该路径存入当前 shell 会话的变量。引号使路径保持为一个整体,&& 只在目录创建成功后执行复制。cp -p 复制文件并保留权限和修改时间。这是源码检查点,不是输出字幕的备份。打印变量并记下实际名称;不要猜测后缀。
rs01_before_one_cue=$(mktemp -d .edit-before-one-cue.XXXXXX) && cp -p src/time.rs "$rs01_before_one_cue/time.rs"
printf '%s\n' "$rs01_before_one_cue"
printf 打印此目录名,再换行。用 nano 打开已有源码;-c 显示光标位置:
nano -c src/time.rs
按 Ctrl+W 搜索 pub fn shift_one,再按 Enter。函数体结束于 pub fn shift_all 前顶层的结束 },而非内部 if 的花括号。保持 milliseconds 不变。删除 _cue 和 _delta 的前导下划线,保留类型和返回签名。现在只实现这个函数;让 shift_all 保持未完成。
用 cue.start.checked_add(delta) 得到局部变量 start,对 cue.end 做对应操作得到 end。通过 ok_or(...)? 为两种溢出分别提供 String 错误。用 if start < 0 || end < 0 检查两个新值,并从该分支返回错误。否则构造 Ok(Cue { start, end, text: cue.text.clone(), line: cue.line })。这样会返回偏移后的副本,而原条目不变。按 Ctrl+O、Enter 保存,再按 Ctrl+X 退出。
运行前预测预备单条目的结果:时间为 4000–7000 ms,偏移 -2000,文本为 A green scene:
cargo run --quiet -- cue -2000
2000 --> 5000 ms | A green scene
这只证明单个条目正常,不能证明整个影片正常。下面有意进行的尝试仍应返回非零,因为 shift_all 未完成。它不能打印创建成功信息,也不能创建指定文件:
cargo run --quiet -- shift data/original.vtt output/unfinished-collection.vtt -2000
ls 列出指定路径。这里它也应返回非零,因为不能存在部分输出:
ls output/unfinished-collection.vtt
现在构建集合操作,不替换任一已完成函数。借用切片 &[Cue] 提供输入记录,Vec<Cue> 拥有新结果。Vec::new 创建空向量,push 追加完整条目。iter().enumerate() 访问每个借用条目,并提供从零开始的下标。map_err 转换错误消息;其闭包 |error| ... 接收原错误,format! 构造 String,包含从 1 开始的条目位置和原始诊断行号。
编辑前,仅为允许修改的源码建立检查点。mktemp -d 用唯一后缀替换六个 X 字符来创建目录;已有名称会被拒绝。$() 将该路径存入当前 shell 会话的变量。引号使路径保持为一个整体,&& 只在目录创建成功后执行复制。cp -p 复制文件并保留权限和修改时间。这是源码检查点,不是输出字幕的备份。打印变量并记下实际名称;不要猜测后缀。
rs01_before_all_cues=$(mktemp -d .edit-before-all-cues.XXXXXX) && cp -p src/time.rs "$rs01_before_all_cues/time.rs"
printf '%s\n' "$rs01_before_all_cues"
printf 打印此目录名,再换行。用 nano 打开已有源码;-c 显示光标位置:
nano -c src/time.rs
搜索 pub fn shift_all。这是文件中最后一个函数;函数体结束于最后一个顶层配对 }。内部 for 和 if 块有自己的结束花括号;不要将其中一个误当成函数边界。删除 _cues 和 _delta 的下划线。只替换未完成的 Err(...) 函数体。先写 let mut shifted = Vec::new();,通过 for (index, cue) in cues.iter().enumerate() 循环,调用刚刚完成的函数。给失败补充上下文:
let next = shift_one(cue, delta)
.map_err(|error| format!("cue {} at line {}: {error}", index + 1, cue.line))?;
在循环内部用 shifted.push(next); 追加 next。循环之后返回 Ok(shifted)。? 必须返回错误,不能跳过坏条目或保留成功前缀。保存、确认文件名并退出 nano。先前的 milliseconds 和 shift_one 逻辑保留在原处。
cat src/time.rs
用现在完整的模块重复单条目回归检查:
cargo run --quiet -- cue -2000
现在调用 shift 命令,提供实际输入路径、新输出路径和有符号的毫秒偏移量:
cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000
Created output/aligned.vtt with 3 cues (offset -2000 ms)
预备的文件边界在创建新文件前,会读取、解析并偏移整个集合。有意重复同一条命令,观察它如何拒绝。下一条命令应返回非零状态,并指出已有的输出:
cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000
已有文件可能是先前运行的正确结果,也可能是早先编辑留下的错误结果。修复代码不会重写它。在重试要求的输出路径前,先保留这份作品。下面的命令创建新的备份目录,并将已有文件移入其中:
mktemp -d 创建新目录,用唯一后缀替换六个 X 字符,不会复用已有备份目录。$() 将打印出的路径存入本次会话的变量 rs01_backup_dir。为变量加引号可使路径保持为一个整体。&& 只在创建目录成功后执行 mv;mv 将已有文件移入刚创建的空目录,因此不会覆盖旧备份。如果创建目录失败,移动操作会跳过,原输出仍留在原处;先阅读错误再重试。如果正在从先前首次运行失败的情况中恢复,只要已有输出存在,也可使用同样的备份操作。
rs01_backup_dir=$(mktemp -d output/aligned-backup.XXXXXX) &&
mv output/aligned.vtt "$rs01_backup_dir/aligned.vtt"
现在使用当前代码重新生成要求的路径:
cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000
它应当创建三个条目。旧结果仍保存在唯一的备份目录中。下一实验会讲解并实现受保护的写入边界本身。
查看实际保存的文件:
cat output/aligned.vtt
WEBVTT
00:00:02.000 --> 00:00:05.000
Café 林地 🌿
Second line intact
00:00:04.000 --> 00:00:07.000
Overlap stays
00:00:59.750 --> 00:01:02.250
Across a minute
前两个条目仍然重叠。第三个条目从第 1 分钟跨回了前一分钟,每一行完整文本都保留了。程序偏移的是整个集合,只让第一个条目正确并不足够。
切换到 Preview。让 Video 保持为 data/edited.webm,将 Caption file 改为 output/aligned.vtt,点击 Load actual files。加载状态中显示的文件名必须是你的输出。预览读取该文件的实际字节,不会重新创建字幕,也不会回退到样例。
在 Time (seconds) 中输入 2.5,点击 Go to time:绿色场景应与两行 Unicode 字幕同时出现。接着查看 4.5:蓝色场景应同时显示第一个条目和 Overlap stays。在 60.5,琥珀色场景应显示 Across a minute。浏览器对相同开始时间的活动条目可能采用不同于文件的显示顺序,但所有完整文本都必须存在。点击 Play 确认真实播放,然后暂停。Download captions 保存加载的输出文件,Download video 保存加载的影片;两个按钮都不会转换内容。
下例显示了实际保存的文件在蓝色场景中的效果。请留意完整的两行 Unicode 字幕、另一条独立的重叠字幕,以及加载的输出文件名。

返回 Terminal。为有意触发路径错误,使用不存在的单数文件名:
cargo run --quiet -- inspect data/caption.vtt
命令应返回非零状态,并指出缺失的路径。文件不存在,与已有文件内出现格式错误条目,是不同的问题。通过指定真实输出进行恢复:
cargo run --quiet -- inspect output/aligned.vtt
你应当再次看到三个偏移后的条目及完整文本。保留原始字幕和视频;修改它们会掩盖转换的效果。
如果误编辑尚未保存,按 Ctrl+X,在保存问题处按 N,即可保持文件不变。对于已保存的错误,阅读 Cargo 指出的文件名和行号,重新打开该源码,修正局部表达式。也可以恢复自己的检查点。one-cue 检查点保存的是本阶段开始前的状态;恢复后,必须重新实现本阶段及其后的函数。
如果打开了新的 Terminal,原有变量和工作目录不会随之保留。先重复可见的环境和目录命令:
export PATH=/home/labex/.cargo/bin:$PATH
export LANG=C.UTF-8 LC_ALL=C.UTF-8
cd /home/labex/project/subtitle-sync
以下恢复命令是有条件的:仅在撤销已保存的错误时使用。ls -d 列出目录名称本身;* 匹配生成的后缀。如果没有检查点,阅读错误并修复源码,不要编造路径。
ls -d .edit-before-one-cue.*
read 等待一行输入,并将它存入会话变量。运行后,输入或粘贴上面打印的一个实际完整目录名,再按 Enter。不要输入示例后缀或美元符号:
read rs01_restore
恢复前,确认文件确实是预期的较早源码:
cat "$rs01_restore/time.rs"
cp -p "$rs01_restore/time.rs" src/time.rs
然后重新打开 nano,重做缺少的实现,再运行先前失败的命令。不要重跑 setup 或修改受保护的输入文件。修复代码不会替换已有输出 VTT,也不会替换浏览器 Blob 中已加载的文件;重新生成要求的路径前先保留旧结果,然后选择该实际输出,再次点击 Load actual files。
.edit-before-one-cue.* 检查点中只有毫秒转换已完成。对于仅限集合操作的错误,使用相同确认流程并选择 .edit-before-all-cues.*:该检查点还保留了完整的单条目函数。两者都不是完整的最终实现。
可选的完整 time.rs 比较与恢复参考
先尝试函数编辑并检查结果,再打开此参考。它展示一种完整实现,不是必须照做的命令序列。公开契约允许等价算法。应比较受影响函数,避免不必要地替换已经完成的工作。
use crate::Cue;
pub fn milliseconds(parts: [i64; 4]) -> Result<i64, String> {
let scales = [3_600_000, 60_000, 1_000, 1];
let mut total = 0_i64;
for i in 0..4 {
let piece = parts[i]
.checked_mul(scales[i])
.ok_or(String::from("time integer overflow"))?;
total = total
.checked_add(piece)
.ok_or(String::from("time integer overflow"))?;
}
Ok(total)
}
pub fn shift_one(cue: &Cue, delta: i64) -> Result<Cue, String> {
let start = cue.start
.checked_add(delta)
.ok_or(String::from("start integer overflow"))?;
let end = cue.end
.checked_add(delta)
.ok_or(String::from("end integer overflow"))?;
if start < 0 || end < 0 {
return Err(String::from("shift would produce negative time"));
}
Ok(Cue { start, end, text: cue.text.clone(), line: cue.line })
}
pub fn shift_all(cues: &[Cue], delta: i64) -> Result<Vec<Cue>, String> {
let mut shifted = Vec::new();
for (index, cue) in cues.iter().enumerate() {
let next = shift_one(cue, delta)
.map_err(|error| format!("cue {} at line {}: {error}", index + 1, cue.line))?;
shifted.push(next);
}
Ok(shifted)
}
总结
你将已验证的时间字段转换为带溢出检查的整数毫秒,偏移了单个条目和整个集合,并保留文本、顺序、持续时间及合法重叠。随后,你把实际新文件加载为视频字幕轨道,检查公开的视觉对齐点,并从错误输入路径中恢复。下一实验将讲解文件边界:它在改变结果文件之前拒绝坏输入和已有输出路径。



