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

언제 — 영상에 번역 자막을 붙일 때. 배치 처리 전제, 실시간 아님. 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