拒绝无效偏移并保留字幕

RustBeginner
立即练习

介绍

正确的字幕转换必须保护整个文件,包括第一个正确样例之后的字幕。靠后的格式错误条目不能留下部分结果,已有输出也不能仅因新一次运行看似成功就被覆盖。

你将为完整字幕转换实现 Rust 文件操作边界,然后检验 Unicode、多行文本、合法重叠、无效偏移、缺失路径和输出冲突。本独立环境已提供上一实验完整的毫秒转换与偏移函数,不需要其 VM 中的任何文件。预备播放器读取你实际选择的输出,不会从原始来源重建字幕。

你应当理解上一实验的格式和时间规则,并具备其中列出的 Rust 前置知识。本实验新增的是仅创建新文件的输出边界,不教授视频编码,也不宣称支持所有 WebVTT 语法。作者估计需 50 分钟,包含阅读和常见恢复操作;这不是初学者实测的完成时间。

创建输出前完成全部验证

本步骤将实现完整的读取、验证和新文件创建顺序。打开 Terminal。将 Cargo 加入当前 shell 的路径,为本会话选择可用的 UTF-8 locale,并进入独立项目:

export PATH=/home/labex/.cargo/bin:$PATH
export LANG=C.UTF-8 LC_ALL=C.UTF-8
cd /home/labex/project/subtitle-sync

每次打开新终端都需重复导出设置;如果不确定当前位置,则重新执行目录切换命令。它们不会编辑启动文件。终端的 Unicode 字形仍可能显示为占位符,但实际 UTF-8 文件字节和浏览器字幕会保留文本。

只编辑 src/export.rs,保留 transform(input: &Path, output: &Path, delta: i64) -> Result<usize, String>。保持所有其他源文件、Cargo.toml、rust-toolchain.toml,以及 data/ 下的原始文件不变。src/time.rs 中已有完整的时间函数,其公开前提仍是由解析器验证过的非负时间字段和有效 Cue 对象。你可以实现功能等价的内部顺序,但所有验证都必须在创建输出前完成。未修改的预备文件须保留字节、权限、所有者/组和修改时间;普通读取带来的访问时间更新不属于此保留要求。

这里继续使用上一实验的受控纯文本 WebVTT 子集。头部必须恰好是 WEBVTT,下一行为空行。时间行采用 HH:MM:SS.mmm --> HH:MM:SS.mmm,空格须与示例完全相同,数字为 ASCII;小时至少两位,分钟和秒各两位且范围为 00–59,毫秒三位。完整时间须可表示为非负的 i64 毫秒。开始时间不能递减;允许相同开始时间和重叠;结束时间必须大于开始时间。每个条目有一行或多行非空逻辑字幕文本。Unicode、制表符、多行文本和首尾空格都要保留,也包括完全由空白字符组成的内容。真正的空行会结束条目,之后的孤立文本应被拒绝。输入允许 LF/CRLF、末尾有或没有换行,以及额外的空分隔行。预备输出使用 LF;0–99 小时写两位小时数字,更大的小时值只增加必要的位数;头部后和每个条目后各有一个空行,包括最后一个条目。它会规范化时间戳写法和分隔行数量,同时保留逻辑字幕文本。本课程不支持单独的回车符、BOM/头部元数据、条目 ID/设置、NOTE/STYLE/REGION、标记/字符引用、字幕文本中的 <、> 或 &,以及除制表符外的控制字符;这些内容会被拒绝,而非悄悄丢弃。

查看同一份真实来源,再有意尝试未完成的导出功能:

cargo run --quiet -- inspect data/original.vtt
cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000

第二条命令应返回非零状态并显示 transform is unfinished,不能创建文件。原字幕从 4000–7000 ms 开始,剪辑后影片的绿色场景从 2000 ms 开始。对每个条目的每个端点,所需偏移仍为 -2000。

阅读解释前,先自行安排这些刻意打乱的操作的安全顺序:创建新目标、渲染文本、解析全部条目、写入字节、读取指定输入、偏移每个条目。读取靠后条目无效的测试素材,预测其第二个条目失败时目标文件是否应存在:

cat data/bad-late.vtt

现在将你的顺序与副作用边界比较。文件边界应遵循以下顺序:首先将实际指定的输入读取为 UTF-8 文本;解析全部条目,带上条目上下文拒绝不受支持或格式错误的内容;再使用带溢出检查的算术偏移整个集合,拒绝负时间和溢出时间;在内存中渲染完整结果。只有这些操作全部成功后,才创建新输出并写入预备好的字节。

如果在完整解析、偏移和渲染前就创建输出,靠后的失败可能留下空文件或部分文件。采用替换式写入则可能毁掉有用的先前结果。OpenOptions 配置文件的打开方式。.write(true).create_new(true) 要求创建可写的新文件;如果路径上已存在任何对象,包括悬空符号链接,打开就会失败,而不会截断该对象。这种原子的仅创建新文件决策避免了先检查存在、再创建的独立操作之间的竞态。它并不保证创建之后发生无关 I/O 失败时仍具有通用的存储事务性。

Path 是借用的文件系统路径。fs::read_to_string 返回完整的 UTF-8 String 或 I/O 错误。map_err 在保留原错误消息的同时补充实际失败的路径;? 立即返回该错误。提供的 parse、shift_all 和 render 处理完整的内存数据。Write 是提供 write_all 的标准 trait;该方法尝试写入预备缓冲区中的每个字节。导入它即可使用此方法,不需要自己实现 trait。as_bytes 借用 String 已有的 UTF-8 字节,不会按字符大小切片。shifted.len() 是条目数,只有在写入成功后才返回。

阅读实际起始代码,包括导入:

cat src/export.rs

它目前只导入 Path。不要假设其他名称已经可用。添加 use std::fs::{self, OpenOptions}; 导入文件系统模块及构建器,添加 use std::io::Write; 导入提供 write_all 方法的 trait,添加 use crate::{parse, render, shift_all}; 导入已准备的库函数。保留 use std::path::Path;。一种清晰的导入顺序是 fs/OpenOptions、Write、Path,再到 crate 函数,与可选参考相同;顺序本身不参与评分。use 分组的花括号导入多个名称,不是函数体。

编辑前,仅为允许修改的源码建立检查点。mktemp -d 用唯一后缀替换六个 X 字符来创建目录;已有名称会被拒绝。$() 将该路径存入当前 shell 会话的变量。引号使路径保持为一个整体,&& 只在目录创建成功后执行复制。cp -p 复制文件并保留权限和修改时间。这是源码检查点,不是输出字幕的备份。打印变量并记下实际名称;不要猜测后缀。

rs01_before_export=$(mktemp -d .edit-before-export.XXXXXX) && cp -p src/export.rs "$rs01_before_export/export.rs"
printf '%s\n' "$rs01_before_export"

printf 打印此目录名,再换行。用 nano 打开已有源码;-c 显示光标位置:

nano -c src/export.rs

按 Ctrl+W 搜索 pub fn transform,再按 Enter。保留类型和返回签名。删除前导下划线,将 _input、_output 和 _delta 重命名。函数体位于该函数起始花括号与最后一个结束花括号之间;修改这个函数体,不要修改已提供的时间模块。

运行前,先在完整函数中实现你的安全顺序。将实际 input 读入 raw,解析为 cues,将全部条目偏移为 shifted,再渲染 complete。对每个可能失败的输入操作,通过 .map_err(|error| format!("{}: {error}", input.display()))? 添加真实输入路径。渲染本身返回 String,而非 Result。然后,通过 OpenOptions::new().write(true).create_new(true).open(output) 配置可变输出文件,并在失败时添加输出路径错误。最后,file.write_all(complete.as_bytes()) 必须成功,才返回 Ok(shifted.len())。向自己解释第一个能够创建目标的表达式是哪一个,以及为什么执行到那里时所有条目都已检查。允许公开行为相同的等价实现。

按 Ctrl+O、Enter、Ctrl+X 保存并离开 nano。读取保存的源码:

cat src/export.rs

让真实来源通过这一边界:

cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000
Created output/aligned.vtt with 3 cues (offset -2000 ms)
cargo run --quiet -- inspect output/aligned.vtt
Cue 1: 2000 --> 5000 ms
Café 林地 🌿
Second line intact
Cue 2: 4000 --> 7000 ms
Overlap stays
Cue 3: 59750 --> 62250 ms
Across a minute

三个条目全部存在,包括两行文本和重叠区间。如果早先尝试后输出已存在,请保留它:下一步会给出不覆盖旧结果的恢复路径,再重新创建这个确切的文件名。

如果误编辑尚未保存,按 Ctrl+X,在保存问题处按 N,即可保持文件不变。对于已保存的错误,阅读 Cargo 指出的文件名和行号,重新打开该源码,修正局部表达式。也可以恢复自己的检查点。export 检查点保存的是本阶段开始前的状态;恢复后,必须重新实现本阶段及其后的函数。

如果打开了新的 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-export.*

read 等待一行输入,并将它存入会话变量。运行后,输入或粘贴上面打印的一个实际完整目录名,再按 Enter。不要输入示例后缀或美元符号:

read rs01_restore

恢复前,确认文件确实是预期的较早源码:

cat "$rs01_restore/export.rs"
cp -p "$rs01_restore/export.rs" src/export.rs

然后重新打开 nano,重做缺少的实现,再运行先前失败的命令。不要重跑 setup 或修改受保护的输入文件。修复代码不会替换已有输出 VTT,也不会替换浏览器 Blob 中已加载的文件;重新生成要求的路径前先保留旧结果,然后选择该实际输出,再次点击 Load actual files。

可选的完整 export.rs 比较与恢复参考

先尝试函数编辑并检查结果,再打开此参考。它展示一种完整实现,不是必须照做的命令序列。公开契约允许等价算法。应比较受影响函数,避免不必要地替换已经完成的工作。

use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::Path;
use crate::{parse, render, shift_all};

pub fn transform(input: &Path, output: &Path, delta: i64) -> Result<usize, String> {
    let raw = fs::read_to_string(input)
        .map_err(|error| format!("{}: {error}", input.display()))?;
    let cues = parse(&raw)
        .map_err(|error| format!("{}: {error}", input.display()))?;
    let shifted = shift_all(&cues, delta)
        .map_err(|error| format!("{}: {error}", input.display()))?;
    let complete = render(&shifted);
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(output)
        .map_err(|error| format!("{}: cannot create new output: {error}", output.display()))?;
    file.write_all(complete.as_bytes())
        .map_err(|error| format!("{}: write failed: {error}", output.display()))?;
    Ok(shifted.len())
}

保留证据并从错误中恢复

本步骤将区分文件冲突、格式错误条目和无效偏移,再确认真实视频播放。继续在 Terminal 的项目目录中操作。下面的失败都是有意触发的,应返回非零状态;不能打印成功消息,也不能创建要求的新输出。

首先对已有结果重复刚才成功的命令:

cargo run --quiet -- shift data/original.vtt output/aligned.vtt -2000

预期出现指出已有 output/aligned.vtt 的错误。该文件应保持不变。为了在重建要求的路径前保留旧结果,请创建新备份目录。mktemp -d 用唯一后缀替换六个 X 字符,拒绝复用已有目录。$() 将该路径存入会话变量,引号使它保持为一个整体。&& 仅在成功创建目录后执行 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 data/bad-late.vtt

第一个条目是正确的,但第 2 个条目的结束时间早于开始时间。只导出正确的前缀并不能得到可用结果。下一条命令应返回非零状态,指出实际来源、第 2 个条目及其时间行:

cargo run --quiet -- shift data/bad-late.vtt output/bad-result.vtt 0

使用列出指定路径的 ls,检查没有出现输出。因为结果不存在,这条命令也应返回非零状态:

ls output/bad-result.vtt

选择有效来源和另一个新输出进行恢复:

cargo run --quiet -- shift data/original.vtt output/after-bad-input.vtt -2000

错误路径是另一类问题。有意把文件名拼成错误的复数形式:

cargo run --quiet -- shift data/originals.vtt output/after-path-error.vtt -2000

它应指出缺失的输入路径,而非编造条目错误。纠正路径:

cargo run --quiet -- shift data/original.vtt output/after-path-error.vtt -2000

-4001 的偏移会使第一个 4000 ms 的开始时间变为负数。下一条命令必须拒绝偏移,而非让它绕回巨大的正数或进行截断:

cargo run --quiet -- shift data/original.vtt output/negative-result.vtt -4001

一个非常大、但仍然有效的 i64 偏移量,在与端点相加时可能溢出。这类失败也必须返回可归因的错误,且不创建输出:

cargo run --quiet -- shift data/original.vtt output/overflow-result.vtt 9223372036854775807

超出 i64 范围的数字会更早在参数解析阶段被拒绝。这仍然是可恢复的错误,不是 panic:

cargo run --quiet -- shift data/original.vtt output/invalid-offset.vtt 9223372036854775808

使用已知有效的偏移量和另一个新文件名进行恢复:

cargo run --quiet -- shift data/original.vtt output/after-offset-errors.vtt -2000

以上所有失败都保留了原字幕和影片。i64 极值案例验证的是算术,并非巨大时间值下精确到毫秒的浏览器播放;这里的浏览器观察只针对这部短片。

查看另一个有效来源:它包含 CRLF 换行、相同开始时间的重叠条目、只含空白字符的内容,以及 Unicode 文本:

cargo run --quiet -- inspect data/whitespace.vtt

其中有一行字幕文本只由空格和一个制表符组成。该行非空,因此属于其条目。真正的空行才会分隔条目;空的字幕内容或孤立的文本续行不受支持。预备读取器保留这一区别,绝不会悄悄过滤掉条目。将这个有效案例跨分钟导出:

cargo run --quiet -- shift data/whitespace.vtt output/whitespace-shifted.vtt 58001

切换到 Preview。让 Video 保持为 data/edited.webm,选择 Caption file 为 output/aligned.vtt,点击 Load actual files。在 Time (seconds) 中输入 2.5,点击 Go to time,查看绿色场景和完整的两行 Unicode 字幕。在 4.5,应看到两个重叠条目;在 60.5,琥珀色场景应显示跨分钟条目。点击 Play 确认真实播放,然后暂停。加载的文件名标识你的实际结果。下载按钮保存加载的文件,不会重建它们。

你也可以选择 output/whitespace-shifted.vtt,查看 60.5。两个开始时间相同的条目都已加载;完全由空白字符组成的字幕在视觉上可能为空白。保存的文件和加载的条目文本保留另一条目的首尾空格和第二行,尽管浏览器排版不一定将每个空格或制表符显示为可分辨的宽度。精确的逻辑文本应从文件中检查,而不能根据视觉间距推断。开始时间相同时,浏览器的活动条目顺序可能不同,但输出文件中的条目顺序仍然保留。

返回 Terminal,最后再检查一次主要的接受结果:

cargo run --quiet -- inspect output/aligned.vtt

如果命令存在拼写或路径错误,应纠正命令,而非编辑受保护的数据。如果 Rust 代码有误,请使用步骤 1 中仅针对源码的检查点确认流程,或可选比较参考,修复受影响函数,再重新运行。修复代码后要重新生成已有输出时,先使用唯一目录备份操作保留旧文件。现在你得到的是完整结果以及先前证据,而非仅仅看起来成功的部分文件。

总结

你实现了完整字幕验证与转换之后的文件副作用边界。已有输出路径被拒绝而不被替换,靠后的坏数据没有留下部分导出,普通路径、负时间和溢出错误仍可恢复。你将先前作品保存在唯一备份目录中,并用真实播放器验证了实际 Unicode、多行和重叠字幕。挑战将针对另一部短片和不同的公开偏移量,组合这些已经学过的边界。