영상 → 번역 자막 파이프라인 (온디바이스)

언제 — 영상에 번역 자막을 붙일 때. 배치 처리 전제, 실시간 아님. 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 클램프 때문에 짧은 청크가 늘어나면 시간이 문장 구간을 넘어가고, 그 오차가 누적되어 뒤로 갈수록 자막이 밀린다. 문장 단위로 ts.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 를 유지해야 하면 자막을 사이드카 파일로 둔다.


검증 체크리스트

위에서부터 막히면 아래는 볼 필요 없다.

  1. 단어 타임스탬프가 실제 발화와 맞는가 — 영상 중간·끝에서 3곳 샘플링. 여기가 틀리면 전체 설계가 무너진다
  2. 문장 수 = 번역 줄 수인가
  3. 문장 id 가 R6 전후로 보존됐는가
  4. 큐 시간이 겹치거나 역행하지 않는가
  5. 영상 끝부분 자막이 밀리지 않았는가 (누적 오차)
  6. CPS 위반 큐가 몇 %인가

실패 지점

증상 원인 조치
전사가 비어 있음 언어 asset 미다운로드 / locale 불일치 R0, R3
음차 표기로 나옴 locale 틀림 R3 후 재전사
파일이 안 열림 미지원 컨테이너 R2
번역 어순이 깨짐 큐 단위로 번역함 R4 후 번역
뒤로 갈수록 자막이 밀림 R7 minDur 클램프 누적 청크 수 축소
번역문 개수가 안 맞음 R6 에서 문장 병합 보정 결과 폐기, id 검사 강화

아직 안 정한 것maxCharsPerLine·maxCps 실측값, R6 를 넣을 가치가 있는지(보정 전후 diff 로 판단), 화자 분리(현재 파이프라인에 없음).

#591

Chrome 외부 앱 딥링크 "항상 열기" 해제

언제 — 커스텀 스킴(myapp://) 다이얼로그에서 “항상 열기 허용”을 실수로 체크해 되돌리고 싶을 때. 설정 UI 에는 항목이 없고 실제 상태는 프로필의 JSON 에 origin 단위로 저장된다.

  • 체크는 Preferencesprotocol_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_schemestrue 로 들어간 스킴은 “차단” 상태이니 반대로 차단을 풀고 싶을 때 같이 본다.


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 쪽 문제다.

반대로 스킴 자체를 막고 싶으면 URLBlocklistmyapp://* 를 넣는다.


R5. macOS — Chrome 밖일 가능성

Chrome 이 아니라 OS(Launch Services)가 스킴을 확인 없이 넘기는 경우.

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \
  -dump | grep -B5 -A5 "myapp:"

앱이 스킴 핸들러로 등록돼 있으면 여기서 확인된다.

#593