자막 블록 모델링하고 이동하기

RustBeginner
지금 연습하기

소개

짧은 영상에서 2초 길이의 시작 타이틀을 제거했지만, 자막은 여전히 원래 타임라인을 사용합니다. 첫 초록 장면은 이제 2초에 시작하는데 자막은 4초에 시작합니다. 화면의 장면 카드가 시간을 보여 주므로 소리를 듣고 추측할 필요가 없습니다.

자막 시간을 정수 밀리초로 표현하고, 완전한 Unicode와 여러 줄 텍스트를 보존하면서 모든 자막 블록을 이동하는 Rust 변환을 구현합니다. 그다음 실제 내보낸 파일을 브라우저 영상의 자막 트랙으로 불러옵니다. 다음 실습에서는 출력을 보호하는 쓰기 경계와 일반적인 실패에서 복구하는 방법에 집중합니다. 영상과 자막은 이 과정을 위해 직접 만든 합성 실습 데이터이며 실제 사건의 녹화물이 아닙니다.

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 디렉터리를 이 셸의 명령 검색 경로에 추가하고, 이 세션에서 사용 가능한 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시간에는 시를 두 자리로 표시하며 더 큰 시간에는 필요한 자리만 추가합니다. 헤더 뒤와 마지막을 포함한 각 블록 뒤에 빈 줄 하나를 둡니다. 시간 표기와 구분 줄 수는 정규화하지만 논리적 본문 텍스트는 바꾸지 않습니다. 단독 캐리지 리턴은 지원하지 않습니다.
  • 블록 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을 텍스트를 소유하는 String에 담긴 오류 메시지가 있는 Result로 바꿉니다. ?는 성공 값으로 계속 진행하거나 오류를 즉시 반환합니다. 오버플로는 일반적인 입력 거부로 처리하며 panic이나 범위를 돌아 다른 값이 된 시간으로 처리하지 않습니다.

코딩 전에 00:01:01.750의 합계를 예상해 보세요. 0이 아닌 각 필드가 얼마나 기여하나요? 예상 정수를 적고 아래 실행으로 확인합니다.

먼저 실제 시작 코드를 읽으세요.

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으로 시작하세요. 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로 부호 있는 오프셋을 양쪽 시간 경계에 각각 더한 다음 음수 결과를 거부합니다. 음수를 부호 없는 정수로 바꾸거나 절댓값을 취하거나 0으로 잘라 맞추지 마세요. 그런 지름길은 요구한 시간을 바꿉니다.

반환할 구조체는 새 시작·종료 시각을 유지하고 원본 블록이 소유한 전체 텍스트를 복제하며 진단 줄을 복사합니다. .clone()은 Unicode와 모든 텍스트 줄을 포함한 String 전체를 보존합니다. 구조체 필드 축약 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를 누르세요. 본문은 안쪽 if 중괄호가 아니라 pub fn shift_all 바로 앞의 최상위 닫는 }에서 끝납니다. 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이 미완성이므로 여전히 0이 아닌 상태를 반환합니다. 생성 성공을 출력하거나 지정 파일을 만들면 안 됩니다.

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

ls는 지정 경로를 나열합니다. 부분 출력이 없어야 하므로 이 명령도 0이 아닌 상태를 반환해야 합니다.

ls output/unfinished-collection.vtt

이제 완성된 두 함수를 교체하지 않고 컬렉션 연산을 만드세요. 빌린 슬라이스 &[Cue]는 입력 레코드를 제공하고 Vec<Cue>는 새 결과를 소유합니다. Vec::new는 빈 벡터를 만들고 push는 완전한 블록 하나를 추가합니다. iter().enumerate()는 빌린 각 블록을 0부터 시작하는 인덱스와 함께 방문합니다. map_err는 오류 메시지를 바꾸고 클로저 |error| ...는 이전 오류를 받습니다. format!은 1부터 세는 블록 위치와 원래 진단 줄을 포함한 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))?;

반복문 안에서 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)

준비된 파일 경계는 새 파일을 만들기 전에 전체 컬렉션을 읽고 분석하고 이동합니다. 같은 명령을 의도적으로 반복해 거부를 확인합니다. 다음 명령은 0이 아닌 상태를 반환하고 기존 출력을 알려야 합니다.

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

0이 아닌 상태가 반환되고 없는 경로를 알려야 합니다. 파일이 없는 것과 기존 파일 안의 블록 형식이 잘못된 것은 다릅니다. 실제 출력을 지정해 복구합니다.

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)
}

요약

검증된 시간 필드를 오버플로를 검사하는 정수 밀리초로 변환하고, 블록 하나와 전체 컬렉션을 이동하면서 텍스트, 순서, 길이, 유효한 중첩을 보존했습니다. 그다음 실제 새 파일을 영상 자막 트랙으로 불러와 공개된 시각적 동기화 시점을 확인하고 잘못된 입력 경로에서 복구했습니다. 다음 실습에서는 결과 파일을 바꾸기 전에 잘못된 입력과 이미 존재하는 출력 경로를 거부하는 파일 경계를 배웁니다.