Моделирование и сдвиг блоков субтитров

RustBeginner
Практиковаться сейчас

Введение

Из короткого фильма удалили начальную заставку длиной две секунды, но субтитры всё ещё используют исходную шкалу времени. Первая зелёная сцена теперь начинается через 2 секунды от начала, а её субтитр — через 4 секунды. Видимые карточки сцен показывают время, поэтому угадывать на слух не нужно.

Вы представите время субтитров целыми миллисекундами и реализуете преобразование на Rust, которое сдвигает каждый блок, сохраняя полный Unicode- и многострочный текст. Затем вы загрузите фактически экспортированный файл как дорожку субтитров видео в браузере. Следующая работа посвящена границе защищённой записи результата и восстановлению после обычных ошибок. Фильмы и субтитры — оригинальные синтетические данные курса, а не записи реальных событий.

Предполагается, что вы уже понимаете структуры, перечисления, векторы, Result, строки, операции с файлами, а также простые тесты и циклы Rust. Прохождение существующего курса по CLI на Rust полезно, но необязательно: разбор аргументов предоставлен, знания 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 в путь поиска команд текущей оболочки и выберите доступную локаль UTF-8 для этой сессии:

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

$PATH сохраняет существующие каталоги поиска. Эти экспорты действуют в текущей сессии терминала и не редактируют файлы запуска оболочки; повторяйте их в каждом новом терминале. Шрифт терминала всё ещё может показывать некоторые символы 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 часов (выше — только необходимые дополнительные цифры), одну пустую строку после заголовка и одну после каждого блока, включая последний. Он нормализует запись временных меток и количество разделителей; логический текст содержимого не меняется. Отдельные возвраты каретки не поддерживаются.
  • Идентификаторы блоков, настройки, 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 с сообщением об ошибке в строке String, владеющей текстом. ? продолжает работу с успешным значением или сразу возвращает ошибку. Переполнение — обычное отклонение входа, а не panic или переход временного значения через границу целочисленного диапазона с получением другого значения.

Перед написанием кода предскажите итог для 00:01:01.750: сколько даёт каждое ненулевое поле? Запишите ожидаемое целое число, затем проверьте предположение запуском ниже.

Сначала прочитайте настоящий начальный код:

cat src/time.rs

use crate::Cue импортирует подготовленный тип из этой библиотеки. Сохраните этот импорт. В файле три функции; сейчас редактируйте только milliseconds. Её открывающая { находится в строке сигнатуры, а парная закрывающая } — непосредственно перед shift_one. Обе функции сдвига на этом этапе должны сохранить незавершённые тела.

Перед редактированием создайте контрольную копию только разрешённого исходника. mktemp -d создаёт уникальный каталог, заменяя шесть символов X; существующее имя не используется. $() сохраняет этот путь в переменной текущей сессии оболочки. Кавычки сохраняют путь целиком, а && выполняет копирование только после успешного создания каталога. 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

Шестьдесят секунд плюс одна секунда и 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; существующее имя не используется. $() сохраняет этот путь в переменной текущей сессии оболочки. Кавычки сохраняют путь целиком, а && выполняет копирование только после успешного создания каталога. 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 оставьте незавершённой.

Получите локальную start через cue.start.checked_add(delta), а end — соответствующей операцией над cue.end. Для каждого переполнения задайте собственную ошибку String через ok_or(...)?. Проверьте оба новых значения условием 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 с позицией блока от единицы и исходной диагностической строкой.

Перед редактированием создайте контрольную копию только разрешённого исходника. mktemp -d создаёт уникальный каталог, заменяя шесть символов X; существующее имя не используется. $() сохраняет этот путь в переменной текущей сессии оболочки. Кавычки сохраняют путь целиком, а && выполняет копирование только после успешного создания каталога. 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))?;

Добавляйте next внутри цикла через shifted.push(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. Выбранное имя файла в состоянии загрузки должно указывать на ваш результат. Предпросмотр читает фактические байты этого файла; он не создаёт субтитры заново и не возвращается к образцу.

Введите 2.5 в Time (seconds) и нажмите 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)
}

Резюме

Вы преобразовали проверенные поля времени в целые миллисекунды с проверкой переполнения, сдвинули один блок и полную коллекцию, сохранив текст, порядок, длительность и допустимые перекрытия. Затем загрузили настоящий новый файл как дорожку субтитров видео, проверили опубликованные визуальные точки синхронизации и исправили неправильный входной путь. Следующая работа объясняет файловую границу, которая отклоняет неправильный вход и существующие выходные пути до изменения файла результата.