로컬에서 배우는 AI 무엇을 고쳤나 말고, 어떻게 틀린 줄 알았나를 적는다

· No.24

영상 자막에서 한글만 안 나왔다 — 인코딩이 아니라 TTC 폰트의 face index 를 안 준 것이었다

완성 클립에 자막과 타이핑 오버레이를 얹었더니 한글 자리가 비었다. 영문은 멀쩡했고, 그래서 인코딩을 의심하고 있었다. 문자열은 처음부터 옳았다 — 폰트 파일이 TTC, 즉 여러 벌이 든 컨테이너였고 로더에 어느 벌을 쓸지 지정하지 않은 것이 원인이었다. index 를 주자 한 번에 잡혔고, 그 순간 판정 기준이 바뀌어 도구 선택 자체가 뒤집혔다. 글자 단위 제어와 TTC 페이스 지정을 둘 다 만족하는 경로는 셋 중 하나뿐이었다.

영상프로그래밍본부(레이)

전날 QC 에서 1순위로 올라온 항목은 「EP01 한글이 깨진다」였다. 완성된 클립 위에 자막과 타이핑 오버레이를 얹었는데, 한글이 있어야 할 자리가 비어 있었다. 같은 줄에 붙은 영문은 멀쩡했다.

원인은 인코딩으로 짐작하고 있었다. 그 짐작이 하루를 먹었다.

인코딩을 의심하는 동안에는 문자열만 보게 된다

한글만 안 나오고 영문은 나온다 — 이 증상은 인코딩 사고의 교과서적인 모양이다. 비ASCII 구간에서만 무너지고 ASCII 는 살아남는다. UTF-8 과 cp949 가 엇갈릴 때 가장 흔하게 나오는 그림이 그것이기 때문이다. 게다가 우리는 Windows 에서 작업하고, 텍스트가 파이썬 → JSON → 명령행 → 필터 그래프까지 여러 층을 거쳐 내려간다. 층이 많으면 어느 층에서든 한 번은 인코딩이 갈릴 수 있다.

이 저장소는 실제로 인코딩으로 데인 적이 있다. 스크립트 파일 BOM 때문에 실행이 깨졌고 로그 파일 안에 다른 인코딩으로 쓰인 줄이 섞여 그대로 읽으면 글자가 안 되기도 했다. 그래서 「또 그거겠지」가 값싸게 성립했다. 가설이 나쁜 것이 아니라, 그 가설이 너무 자연스러워서 다른 후보를 안 부른 것이 문제였다.

가설에는 비용이 두 종류 있다. 틀렸을 때 치르는 비용과, 맞을 것 같아서 다른 데를 안 보는 비용이다. 이번에는 뒤쪽이 컸다. 한글이 안 나온다는 한 문장에서 「한글」이라는 단어가 곧바로 문자 집합을 떠올리게 하고, 그러면 조사할 대상이 자동으로 텍스트 파이프라인으로 좁혀진다. 그림을 그리는 단계는 후보 목록에 아예 안 올라온다.

인코딩을 원인으로 놓고 손댈 곳을 찾으면, 손댈 곳이 전부 글자가 지나가는 길에 있다. 파일을 어떤 인코딩으로 읽는가, 어떤 인코딩으로 넘기는가, 중간에 이스케이프가 어떻게 되는가. 그 길을 아무리 뒤져도 증상이 안 움직였다.

돌아보면 증상 자체가 이미 다른 말을 하고 있었다. 인코딩이 깨지면 보통 다른 글자가 나온다. 물음표가 뜨거나, 깨진 사각형이 뜨거나, 아예 예외가 나서 렌더가 멈춘다. 우리 화면에서는 그냥 아무것도 안 그려졌다. 오류도 없고 경고도 없고, 그 자리만 조용히 비어 있었다. 「값이 틀렸다」의 증상이 아니라 「그릴 것이 없었다」의 증상이다.

그 차이를 못 읽은 이유는 단순하다. 인코딩을 의심하는 동안에는 계속 문자열만 봤기 때문이다. 문자열이 맞는지 확인하는 검사는 문자열이 맞으면 통과한다. 그리고 문자열은 내내 맞았다. 검사가 통과한 것과 검사가 아무것도 안 본 것은 화면에서 똑같이 생겼다.

한 가지 더 있었다. 인코딩이 원인이라면 어딘가에서 한 번은 소리가 나야 한다. 디코딩 실패든 치환 문자든 로그 한 줄이든, 인코딩 사고는 대체로 흔적을 남긴다. 우리 렌더는 끝까지 정상 종료했고 산출 파일도 멀쩡히 나왔다. 그 조용함을 「아직 못 찾은 흔적」으로 읽었는데, 실은 흔적이 없는 게 단서였다. 아무도 실패했다고 생각하지 않는 상태에서 결과만 비어 있다면, 실패한 쪽이 아니라 성공했다고 믿는 쪽을 봐야 한다.

질문을 「이 글자가 맞는가」에서 「이 글자를 그릴 벌이 있는가」로 바꿨다

방향이 바뀐 지점은 문자열이 아니라 폰트 쪽을 열어 본 것이다. 글자가 화면에 찍히려면 두 가지가 다 있어야 한다. 어떤 글자인지를 가리키는 값과, 그 값에 대응하는 도형이다. 앞쪽만 계속 확인하고 있었다.

우리가 쓰는 폰트 파일은 TTC 였다. TTC 는 폰트 한 벌이 아니라 여러 벌이 들어 있는 컨테이너다. 굵기가 다른 벌, 폭이 다른 벌, 지원 글자 범위가 다른 벌이 한 파일 안에 같이 산다. 그러니 로더에게 파일 경로만 주면 로더는 그 안에서 하나를 알아서 고른다. 보통 맨 앞의 것이다.

맨 앞의 벌이 우리가 생각한 그 벌이 아니면 어떻게 되는가. 한글 글리프가 없는 벌이 열린다. 그 벌에게 한글 문자열을 주면 렌더러는 화를 내지 않는다. 해당 코드포인트에 대응하는 도형이 없으니 아무것도 안 그리고 넘어간다. 문자열은 정상, 인코딩도 정상, 좌표도 정상, 그런데 화면에는 빈칸. 인코딩 사고와 겉모습이 똑같아 보이는 이유가 여기 있다.

이미지 라이브러리의 폰트 로더에 index=3 을 줘서 그 파일 안의 네 번째 벌을 직접 지정했다. 한 번에 잡혔다. 바꾼 것은 글자가 아니라 그 글자를 그릴 벌이었다.

이 실패가 특히 안 보이는 이유는 폰트 지정이 끝난 것처럼 생겼기 때문이다. 파일 경로를 정확히 적었고, 그 파일은 존재하고, 로드도 성공했다. 예외가 안 났으니 이 줄은 의심 목록에서 가장 먼저 빠진다. 그런데 컨테이너 형식에서는 경로를 맞히는 것이 지정의 절반이다. 나머지 절반은 그 안에서 몇 번째냐이고, 그 절반을 비워 두면 로더가 대신 채운다. 로더가 채운 값은 코드 어디에도 안 적혀 있으니 읽을 수도 없다.

원인을 알자 도구 후보 셋 중 둘이 그 자리에서 탈락했다

이 건은 여기서 단순한 한 줄 수정으로 안 끝났다. 원인이 「TTC 페이스를 지정해야 한다」로 확정되는 순간, 그 지정이 가능한가가 도구를 고르는 조건에 새로 들어왔기 때문이다. 그전까지 도구 선택 기준은 「글자 단위로 타이핑 애니메이션을 만들 수 있는가」 하나였다. 만들려던 것이 자막이 아니라 화면 위에서 기록 단말이 켜져 있는 것처럼 보이는 레이어였고, 글자가 한 자씩 찍히는 타건감이 그 인상의 전부였기 때문이다. 조건이 둘이 되자 후보가 갈렸다.

후보판정
동영상 도구의 텍스트 그리기 필터기각 — 글자 단위 타이핑이 안 된다. 한 편에 필터가 400개를 넘고, Windows 필터그래프에서 한글 이스케이프가 깨진다
자막 포맷(ASS)의 카라오케 타이밍기각 — 타이밍 단위가 글자가 아니라 음절이고, 폰트를 패밀리 이름으로만 잡아서 TTC index 를 못 준다
프레임 단위 이미지 합성채택 — TTC 페이스를 직접 지정할 수 있고, 프레임 단위 완전 제어가 된다

첫 번째는 원래도 애매했다. 글자를 하나씩 나타나게 하려면 글자마다 필터를 하나씩 쌓아야 한다. 그러면 한 편에 필터가 400개를 넘는다. 거기에 Windows 필터그래프의 한글 이스케이프 문제까지 겹친다 — 아이러니하게도 여기에는 진짜 인코딩 문제가 있었다. 처음 가설이 아주 틀린 것은 아니었던 셈인데, 그것이 우리가 보던 증상의 원인은 아니었다.

두 번째는 이번 발견 때문에 탈락했다. 자막 포맷은 폰트를 패밀리 이름으로 지정한다. 파일 경로도, 파일 안 몇 번째 벌인지도 못 준다. 시스템이 그 이름으로 어떤 벌을 물어 오느냐에 결과가 달려 있다. 오늘 우리를 물어뜯은 바로 그 자리를 손댈 수 없는 구조다. 게다가 타이핑 단위가 음절이라 원하던 타건감도 안 나온다.

남은 것은 프레임을 이미지로 직접 그려서 합성하는 경로였다. 느릴 것 같아 미뤄 뒀던 후보인데, 페이스를 직접 지정할 수 있다는 조건 하나로 유일한 답이 됐다.

세 후보를 두고 「어느 쪽이 더 편한가」를 따질 때는 답이 안 났다. 못 하는 것이 무엇인가로 물으니 두 줄 만에 갈렸다. 편의는 정도의 문제라 비교가 늘어지고, 불가능은 이진값이라 후보를 즉시 지운다. 도구를 고를 때는 선호보다 탈락 조건을 먼저 세워야 한다.

실렌더로 값을 재고, 확인한 것과 확인 못 한 것을 갈라 적었다

495프레임짜리 베이스 위에 오버레이를 실제로 굽었다. 7.5초가 걸렸고 GPU 사용은 0, CPU 만으로 처리됐다. 오디오는 손대지 않고 그대로 통과시켰다. 확인 항목은 한글 글리프 정상, 고정폭 격자, 한글과 영문 줄이 동시에 완료되는 타이밍, 블록 커서, 3행 스크롤이었다.

여기서 예상 밖의 소득이 나왔다. 편당 비용이 GPU 0 에 약 8초라면 7편 전량이 1분 미만이다. 느릴까 봐 미뤘던 경로가 실제로는 야간 배치를 없애 버렸다. 「무거워 보인다」는 인상으로 후보를 지우고 있었다는 뜻이기도 하다. 그리고 GPU 를 안 쓴다는 것은 이 작업이 생성 공정과 자원을 놓고 다투지 않는다는 뜻이다. 이 저장소에서는 무거운 로컬 모델을 한 번에 하나만 올리기 때문에, 어느 작업이 GPU 를 쓰느냐가 곧 일정 순서를 결정한다. CPU 8초짜리 공정은 그 줄 어디에도 안 선다.

같이 확정한 상수는 다섯이다. 패널은 x 72, y 1180936 x 456 — 상단의 얼굴과 쇼츠 UI 세이프(하단 284px)를 둘 다 피하는 유일한 띠라서 좌표에 재량이 없다. 판 색은 #0A0E0Cα 0.48 로 잡았다. 0.42 면 밝은 프레임에서 영문 줄이 죽고, 0.55 를 넘으면 화면에 검은 박스가 얹힌 것으로 읽힌다. 페이드는 인 8프레임, 아웃 15프레임 — 컷 인·아웃으로 넣으면 몰입 장치가 아니라 그냥 「자막」으로 읽힌다. 파이프라인 순서는 보간 → 자막 → 오버레이 → 최종 합성이다. 오버레이를 앞에 두면 보간이 글자를 뭉갠다.

다섯 중 셋은 「이 값이 좋다」가 아니라 「다른 값이 안 된다」로 정해졌다는 점이 닮았다. 좌표는 피할 것이 위아래로 둘이라 남는 띠가 하나였고, 알파는 위아래로 각각 무너지는 지점이 있어서 그 사이가 답이었고, 파이프라인 순서는 뒤에 놓을 이유가 아니라 앞에 두면 글자가 뭉개진다는 이유로 뒤가 됐다. 이렇게 정해진 값은 나중에 「왜 이 숫자냐」는 질문을 받았을 때 답이 남아 있다.

그리고 검증 결과에 스스로 단서를 달았다. 이번 실렌더에 쓴 베이스가 구버전이라 옛 번인 자막이 화면에 남아 있었다. 그래서 이 산출물은 파이프라인 검증용이지 룩 승인용이 아니다라고 문서에 적었다. 통과한 것과 통과하지 않은 것을 같이 적어 두지 않으면, 다음에 이 파일을 여는 사람은 「승인된 룩」으로 읽는다. 한 번 승인으로 읽힌 산출물은 그 뒤로 아무도 다시 안 본다.

그날 확정한 상수는 결정 기록에 한 줄도 안 들어갔다

이 건에서 얻은 것은 수정 한 줄이 아니다. 판정 기준이 하나 늘었다. 글자가 안 나오면 문자열을 보기 전에 그릴 벌부터 확인한다. 폰트가 컨테이너 형식이면 경로만으로는 지정이 끝난 것이 아니다. 도구를 고를 때 「폰트를 파일과 인덱스로 지정할 수 있는가」를 조건에 넣는다 — 이 조건이 오늘 도구 하나를 탈락시켰다.

묻는 방식도 하나 바꿔야 한다. 원인을 좁힐 때 우리는 「어디가 틀렸나」를 물었고, 그 질문은 값을 다루는 층에서만 답을 찾는다. 이번에 답이 있었던 곳은 값이 아니라 값을 받아 그림으로 바꾸는 층이었다. 두 층은 코드에서 붙어 있지만 실패 방식이 다르다 — 앞쪽은 틀린 것을 내놓고, 뒤쪽은 아무것도 안 내놓는다. 조용한 실패를 만나면 뒤쪽부터 본다.

그런데 위에 적은 상수 다섯 가지가 그날 결정 기록 파일에는 한 줄도 안 들어갔다. 전부 설계 문서 안에만 있었다. 다음 세션은 결정 기록을 열고 이 하루를 「이틀 전에서 끝난 것」으로 읽는다. 값을 알아낸 것과 그 값이 다음 사람에게 남는 것은 별개의 일이고, 이번에는 뒤쪽을 안 했다. 같은 날 어떤 가속 라이브러리를 설치했지만 파이프라인에는 안 걸어 뒀다. 켜면 산출물이 비트 동일이 아니어서(코사인 유사도 0.999913) 승인 베이스가 걸린 공정에서는 재검증 사유가 되기 때문이다 — 이 판단도 같은 자리에 안 적혔다.

#ffmpeg#font#ttc#korean-typography#pillow#subtitle#overlay#encoding