영상 → 번역 자막 파이프라인 (온디바이스)
언제 — 영상에 번역 자막을 붙일 때. 배치 처리 전제, 실시간 아님. STT 는 만들지 않고 이미 있는 걸 쓴다 — 새로 만드는 건 R4 이후 전부다.
video ──(R1)──> 단어 타임스탬프 JSON ──(R4)──> 문장 ──(R5)──> 번역
(R6) 문맥 보정 ──┤
(R7) 자막 큐 ──> (R8) VTT ──> (R9) mux
R0. 환경 확인
sw_vers -productVersion # macOS 26 이상
brew install finnvoor/tap/yap
yap --help # --json, --word-timestamps, --locale 이 보이면 정상
함정 — 언어 asset 은 최초 사용 시 다운로드된다. 오프라인에서 첫 실행하면 실패한다. 대상 언어로 짧은 파일 하나를 미리 돌려 받아둔다.
R1. 영상에서 바로 전사
yap transcribe input.mp4 --json --word-timestamps -o out.json
ffmpeg 없이 mp4/mov 에서 단어별 시작/끝 시간이 나온다.
함정 — --srt 로 받으면 안 된다. 큐 경계가 이미 고정되어 R7 에서 재분할할 수 없다. 이 파이프라인 전체가 단어 타임스탬프 위에 서 있다.
R2. 열리지 않는 파일 fallback
R1 이 실패했을 때만 실행한다. 무조건 앞에 두지 않는다.
ffmpeg -i input.mkv -vn -ac 1 -c:a pcm_s16le -ar 16000 audio.wav
yap transcribe audio.wav --json --word-timestamps -o out.json
함정 — sample rate 를 임의로 고정하지 말 것. 리샘플링이 한 번 더 들어가면 손해다. 위 16k 는 관례값일 뿐이므로 품질이 의심되면 -ar 없이 원본 rate 로 뽑아 비교한다.
R3. 언어 판별 후 재전사
소스 언어를 모를 때. 배치이므로 두 번 돌려도 된다.
ffmpeg -i input.mp4 -t 60 -vn -c:a pcm_s16le probe.wav # 앞 60초만
yap transcribe probe.wav --txt -o probe.txt
probe.txt 를 판별기(franc 정도로 충분)에 넣어 locale 을 확정한 뒤 R1 을 그 locale 로 재실행한다.
함정 — 1차 전사를 틀린 locale 로 돌리면 출력이 음차 표기로 나온다. 그래도 언어 판별에는 충분한 신호가 남으니 결과가 이상하다고 겁먹지 말 것.
R4. 단어 → 문장 재조립
큐 단위로 번역하면 어순이 깨진다. 번역 단위를 문장으로 만든다. 문장 분리는 Node 내장 Intl.Segmenter 로 하면 Swift 브릿지가 필요 없다.
type Word = { text: string; start: number; end: number } // 초 단위
type Sentence = { text: string; start: number; end: number }
function toSentences(words: Word[], locale: string): Sentence[] {
const full = words.map(w => w.text).join(' ')
const offsets: number[] = [] // 문자 오프셋 → 단어 인덱스
let pos = 0
for (const w of words) { offsets.push(pos); pos += w.text.length + 1 }
const seg = new Intl.Segmenter(locale, { granularity: 'sentence' })
const out: Sentence[] = []
for (const { segment, index } of seg.segment(full)) {
const text = segment.trim()
if (!text) continue
const from = offsets.findIndex(o => o >= index)
const toIdx = offsets.findIndex(o => o >= index + segment.length)
const last = (toIdx === -1 ? words.length : toIdx) - 1
out.push({ text, start: words[from].start, end: words[last].end })
}
return out
}
yap JSON 스키마는 버전에 따라 다르므로 normalize(raw): Word[] 어댑터 하나만 두고 거기만 고치면 나머지가 그대로 돈다.
확인 — 문장 수가 육안 문장 수와 대략 맞고 start 가 단조 증가한다.
함정 — Intl.Segmenter 는 축약형(Dr., etc.)에서 오분할한다. 번역 품질만 조금 떨어질 뿐 타임코드는 안 깨지므로 초기에는 무시하고 넘어간다.
R5. 문장 배치 번역
stdin JSONL → stdout JSONL 인터페이스가 다루기 편하다.
jq -c '.[] | {id, text}' sentences.json | translate-cli --from en --to ko > translated.jsonl
확인 — 입력 문장 수와 출력 줄 수가 정확히 같아야 한다. 다르면 매핑이 어긋나 이후 전부 밀린다. 여기에 assert 를 넣는다.
함정 — 문장을 개별 호출로 돌리면 세션 생성 비용이 지배적이 된다. 반드시 한 세션에서 배치로 넘긴다.
R6. 문맥 보정 (선택)
문장 단위 번역이 놓치는 것을 복구한다 — 용어 통일, 대명사·주어 복원, 고유명사 표기 고정. 배치이므로 전체 전사문을 컨텍스트로 줄 수 있다. 실시간에서는 불가능했던 단계다.
확인 — 보정 전후 diff 를 눈으로 본다. 개선이 아니라 개악인 경우가 흔하다.
함정 — 모델이 문장을 병합하거나 분할하면 R4 의 타임코드 매핑이 무효가 된다. 문장 id 를 반드시 유지시키고, id 집합이 바뀌면 보정 결과를 버린다. 이 단계는 실패해도 파이프라인이 멈추지 않아야 한다.
R7. 번역문 → 자막 큐 재분할
여기가 유일하게 진짜 어려운 부분이다. 문장을 읽을 수 있는 크기로 자르고 시간을 배분한다.
const CFG = { maxCharsPerLine: 18, maxLines: 2, minDur: 1.0, maxDur: 7.0, maxCps: 12 }
function toCues(s: Sentence): Cue[] {
const budget = CFG.maxCharsPerLine * CFG.maxLines
const chunks: string[] = [] // 1) 어절 경계로 청크 분할
let cur = ''
for (const w of s.text.split(/\s+/)) {
if (cur && (cur + ' ' + w).length > budget) { chunks.push(cur); cur = w }
else cur = cur ? cur + ' ' + w : w
}
if (cur) chunks.push(cur)
const total = chunks.reduce((a, c) => a + c.length, 0) // 2) 글자수 비율로 시간 배분
const span = s.end - s.start
let t = s.start
return chunks.map(c => {
const dur = Math.max(CFG.minDur, Math.min(CFG.maxDur, span * (c.length / total)))
const cue = { start: t, end: t + dur, lines: wrap(c) }
t += dur
return cue
})
}
확인 — 큐가 겹치지 않고 각 큐의 CPS 가 maxCps 이하인가.
함정 — minDur 클램프 때문에 짧은 청크가 늘어나면 시간이 문장 구간을 넘어가고, 그 오차가 누적되어 뒤로 갈수록 자막이 밀린다. 문장 단위로 t 를 s.start 로 리셋하므로 문장 경계에서는 복구되지만, 문장 내부에서 밀림이 보이면 클램프 대신 청크 수를 줄이는 쪽으로 조정한다. maxCharsPerLine·maxCps 는 언어와 시청자에 따라 다르므로 실제 영상으로 보면서 맞춘다.
R8. WebVTT 직렬화
SRT 대신 VTT — 스타일과 다중 트랙에 유리하다.
const ts = (sec: number) => {
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${s.toFixed(3).padStart(6,'0')}`
}
const toVtt = (cues: Cue[]) => 'WEBVTT\n\n' + cues.map((c, i) =>
`${i + 1}\n${ts(c.start)} --> ${ts(c.end)}\n${c.lines.join('\n')}\n`).join('\n')
원문과 번역을 각각 별도 파일로 뽑는다. 한 파일에 합치지 않는다.
R9. mux
ffmpeg -i input.mp4 -i ko.vtt -i en.vtt -map 0 -map 1 -map 2 -c copy -c:s webvtt \
-metadata:s:s:0 language=kor -metadata:s:s:1 language=eng output.mkv
함정 — mp4 는 mov_text 만 받으므로 WebVTT 를 넣으려면 mkv 로 나가야 한다. mp4 를 유지해야 하면 자막을 사이드카 파일로 둔다.
검증 체크리스트
위에서부터 막히면 아래는 볼 필요 없다.
- 단어 타임스탬프가 실제 발화와 맞는가 — 영상 중간·끝에서 3곳 샘플링. 여기가 틀리면 전체 설계가 무너진다
- 문장 수 = 번역 줄 수인가
- 문장 id 가 R6 전후로 보존됐는가
- 큐 시간이 겹치거나 역행하지 않는가
- 영상 끝부분 자막이 밀리지 않았는가 (누적 오차)
- CPS 위반 큐가 몇 %인가
실패 지점
| 증상 | 원인 | 조치 |
|---|---|---|
| 전사가 비어 있음 | 언어 asset 미다운로드 / locale 불일치 | R0, R3 |
| 음차 표기로 나옴 | locale 틀림 | R3 후 재전사 |
| 파일이 안 열림 | 미지원 컨테이너 | R2 |
| 번역 어순이 깨짐 | 큐 단위로 번역함 | R4 후 번역 |
| 뒤로 갈수록 자막이 밀림 | R7 minDur 클램프 누적 |
청크 수 축소 |
| 번역문 개수가 안 맞음 | R6 에서 문장 병합 | 보정 결과 폐기, id 검사 강화 |
아직 안 정한 것 — maxCharsPerLine·maxCps 실측값, R6 를 넣을 가치가 있는지(보정 전후 diff 로 판단), 화자 분리(현재 파이프라인에 없음).
Chrome 외부 앱 딥링크 "항상 열기" 해제
언제 — 커스텀 스킴(myapp://) 다이얼로그에서 “항상 열기 허용”을 실수로 체크해 되돌리고 싶을 때. 설정 UI 에는 항목이 없고 실제 상태는 프로필의 JSON 에 origin 단위로 저장된다.
- 체크는
Preferences의protocol_handler.allowed_origin_protocol_pairs에{ "origin": { "scheme": true } }로 저장된다. chrome://settings/handlers는 웹 프로토콜 핸들러(mailto:등 사이트가 등록한 것)만 다룬다. 외부 앱 실행 허용과는 별개다.- Chrome 은 종료 시 메모리 상태를
Preferences에 덮어쓴다 — 실행 중에 파일을 고치면 되돌아간다. - MAC 해시로 보호되는 pref 는
Secure Preferences에 들어가며 손으로 고치면 무시·초기화된다.
다이얼로그를 다시 띄우고 싶다
├─ R1 사이트 데이터 삭제 ─── 해결? → 끝
├─ Chrome 완전 종료
├─ R2 Preferences 편집 ─── protocol_handler 있음? → 편집 → 끝
├─ R3 grep 추적 ─┬─ 다른 프로필/브라우저? → 경로 바꿔 R2
│ ├─ Secure Preferences? → R1
│ └─ chrome://policy → R4
└─ macOS 인데 여전히 안 뜸 → R5 (Launch Services)
R1. 사이트 데이터 삭제 (가장 안전, 우선 시도)
chrome://settings/content/all → 해당 사이트 검색 → 항목 클릭 → “데이터 및 권한 삭제”.
외부 프로토콜 허용이 origin 단위 사이트 데이터에 묶여 있어 함께 초기화되는 경우가 많다.
R2. Preferences 직접 편집
R1 로 안 될 때. 반드시 Chrome 을 완전히 종료(백그라운드 프로세스 포함)한 뒤 진행한다.
| OS | 경로 |
|---|---|
| macOS | ~/Library/Application Support/Google/Chrome/<Profile>/Preferences |
| Windows | %LOCALAPPDATA%\Google\Chrome\User Data\<Profile>\Preferences |
| Linux | ~/.config/google-chrome/<Profile>/Preferences |
<Profile> 은 보통 Default 또는 Profile 1… 정확한 값은 chrome://version 의 “프로필 경로”에서 본다.
한 줄로 압축된 JSON 이라 jq 가 편하다.
jq '.protocol_handler.allowed_origin_protocol_pairs' Preferences # 확인
jq 'del(.protocol_handler.allowed_origin_protocol_pairs["https://example.com"])' \
Preferences > tmp && mv tmp Preferences # 특정 origin
jq 'del(.protocol_handler.allowed_origin_protocol_pairs)' \
Preferences > tmp && mv tmp Preferences # 전체 초기화
확인 — Chrome 재시작 후 다이얼로그가 다시 뜬다. excluded_schemes 에 true 로 들어간 스킴은 “차단” 상태이니 반대로 차단을 풀고 싶을 때 같이 본다.
R3. 안 보일 때 추적
protocol_handler 가 비어 보이거나 없을 때.
grep -l "myapp" Preferences "Secure Preferences" "Local State" 2>/dev/null
grep -o '.\{200\}myapp.\{200\}' Preferences # 한 줄 JSON이라 주변 컨텍스트째로
체크리스트 — 다른 프로필(Default 가 아닐 수 있다, chrome://version 재확인) / 다른 브라우저(Beta·Canary·Chromium·Edge 는 완전히 별도 디렉터리) / Secure Preferences 에 있으면 파일 편집을 포기하고 R1 로 / 검색 실패는 Chrome 실행 중이라 덮어써졌거나 한 줄 JSON 에서 눈으로 놓친 것.
R4. 정책으로 걸린 경우
chrome://policy 에서 AutoLaunchProtocolsFromOrigins 를 본다. 값이 있으면 사용자가 체크한 게 아니라 관리 정책으로 처음부터 다이얼로그가 억제된 것이라 로컬 편집으로 못 지운다. 회사 관리 기기면 IT/MDM 쪽 문제다.
반대로 스킴 자체를 막고 싶으면 URLBlocklist 에 myapp://* 를 넣는다.
R5. macOS — Chrome 밖일 가능성
Chrome 이 아니라 OS(Launch Services)가 스킴을 확인 없이 넘기는 경우.
/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
-dump | grep -B5 -A5 "myapp:"
앱이 스킴 핸들러로 등록돼 있으면 여기서 확인된다.