홈페이지에 올라간 영상이 200개를 넘는데 자막이 붙은 것은 하나도 없었다. 홍보영상, 뉴스에 나온 장면, 교가까지. 영상 아래에 내용을 텍스트로 적어둔 것은 있었다. 그런데 접근성 검수에서 그것으로는 안 된다고 걸렸고, 영상 하나하나에 사람이 자막을 받아 적을 수는 없었다.
결국 음성 인식으로 초안을 뽑고 손으로 다듬어 전부 붙였다. 그 과정에서 알게 된 것들을 정리한다. 자막 파일을 만드는 쪽과 웹에 붙이는 쪽 양쪽 다 함정이 있었고, 특히 "자막 하나로 대본까지 만든다"는 구조가 생각보다 많은 일을 줄여줬다.
지침이 요구하는 것
KWCAG 2.2(한국형 웹 콘텐츠 접근성 지침, KS X OT0003:2022)의 검사항목 5.2.1 자막 제공은 한 문장이다.
멀티미디어 콘텐츠에는 자막, 대본 또는 수어를 제공해야 한다.
셋 중 하나면 된다. 그런데 "영상 밑에 대본을 붙여뒀는데 왜 안 되나"가 이 글의 출발점이었으니, 세 갈래에 붙은 조건을 원문 그대로 봐야 한다. 같은 항목의 해설은 이렇게 이어진다.
가장 바람직한 방법은 폐쇄자막을 오디오와 동기화시켜 제공하는 것이다. 대사 없이 영상만 제공하는 경우, 화면해설(텍스트, 오디오, 대본)을 제공해야 한다. (…)
(1) 자막 제공: 멀티미디어 콘텐츠를 재생시킬 때마다 자동적으로 자막을 화면에 표시할 수 있는 멀티미디어 콘텐츠는 본 검사항목을 준수한 것으로 간주할 수 있다.
(2) 대본 제공: 자막과는 달리 멀티미디어가 재생되는 과정에서 시나리오를 제공하는 경우도 본 검사항목을 준수한 것으로 간주한다.
세 가지가 읽힌다.
- 자막은 동기화가 정의에 들어 있다. 용어 정의(3.1.32)가 "자막은 영상 매체의 진행에 따라 해당 이벤트와 동기화되어야 한다"고 못 박는다. 동기화되지 않은 것은 자막이 아니다.
- 대본도 "재생되는 과정에서" 제공되어야 한다. 영상과 따로 떨어진 텍스트 덩어리가 아니라, 재생을 따라가며 볼 수 있는 시나리오여야 한다는 뜻이다. 인증기관은 이걸 "다운로드 형태는 불인정, 영상과 같이 확인할 수 있도록 인접한 위치에서 제공"으로 운용한다. 영상 아래에 붙여둔 텍스트가 걸린 지점이 여기다.
- 대사 없는 영상은 화면해설 대상이다. 소리가 없다고 빠지는 것이 아니라, 화면에 있는 것을 텍스트·오디오·대본으로 설명해야 한다. 뒤에 나오는 음소거 배경 영상이 이 경우다.
표준 문서는 이렇게 압축적이라 "재생되는 과정에서"가 무슨 뜻인지 바로 안 잡힌다. 같은 기관이 낸 웹 접근성을 고려한 콘텐츠 제작기법 2.2(2025년 3월 개정판)가 그걸 풀어 쓴다. 여기서는 말이 훨씬 직접적이다.
자막과 대본은 오디오 트랙과 동기화되어야 하며, 대화뿐만 아니라 음향 효과, 음악 등 비언어적 소리 정보도 함께 전달해야 한다. (…)
대본은 멀티미디어 콘텐츠 근처에 배치하며, 현재 재생 중인 부분을 식별할 수 있도록 표시한다. 스크롤 가능한 형태로 제공하며, 재생 중인 위치가 화면 내에서 유지되도록 자동 스크롤 기능을 지원하는 것이 바람직하다.
대본에도 "동기화"라는 말이 붙는다. 그리고 그 동기화가 무엇인지가 구체적이다 — 영상 근처, 지금 나오는 문장이 어느 것인지 표시, 그 문장이 화면에 보이도록 따라가는 스크롤. 영상 밑에 텍스트를 붙여둔 것은 이 셋 중 첫 번째만 만족한다. 검수에서 걸린 것이 정확히 이 차이였다.
수어는 현실적으로 어렵다. 남는 것은 동기화된 자막과 동기화된 대본인데, 이 둘은 같은 원천에서 나온다. 이 글의 결론을 먼저 말하면, 동기화된 자막 파일 하나를 만들면 동기화된 대본은 공짜로 따라온다. 정적 텍스트를 붙이는 것보다 어렵지 않으면서, 두 갈래를 동시에 만족한다.
하나 더. 같은 지침의 5.4.2 자동 재생 금지는 "자동으로 소리가 재생되지 않아야 한다"이다. 메인 화면에서 자동 재생되는 배경 영상은 muted라 이 항목에는 걸리지 않는데, 그러면 위의 "대사 없이 영상만 제공하는 경우"가 된다. 뒤에서 다룬다.
형식은 WebVTT 하나다
자막 파일 형식은 여럿이지만(SRT, ASS, SBV…), 브라우저의 <track>이 읽는 것은 WebVTT뿐이다. 다른 형식은 변환해야 한다. 다행히 구조가 단순하다.
WEBVTT
00:00:00.000 --> 00:00:02.500
입학 안내는 홈페이지에서
00:00:02.500 --> 00:00:05.100
확인하실 수 있습니다.
00:00:06.000 --> 00:00:08.400
문의는 입학처로 연락 주세요.
첫 줄 WEBVTT, 빈 줄, 그다음부터 큐(cue)가 반복된다. 큐는 시작·끝 시각과 텍스트다. 시각은 시:분:초.밀리초이고, 시는 생략할 수 있다. 큐 사이에 빈 줄이 있어야 한다.
SRT와 거의 같아 보이지만 다른 점이 있다. 시각 구분자가 쉼표가 아니라 마침표이고(00:00:02,500은 SRT), 큐 번호가 없어도 되고, 파일 첫 줄이 반드시 WEBVTT여야 한다. SRT를 그대로 이름만 바꾸면 안 읽힌다.
만들기 — 음성 인식으로 초안 뽑기
도구는 whisper.cpp를 썼다. OpenAI Whisper 모델을 C++로 돌리는 것이라 GPU 없이 맥에서 돌아가고, 한국어 인식률이 실용 수준이다. 무엇보다 -ovtt 옵션으로 WebVTT를 바로 뱉는다.
# 1. 영상에서 소리만, 16kHz 모노로
ffmpeg -i video.mp4 -ar 16000 -ac 1 audio.wav
# 2. 한국어로 인식해서 WebVTT 로
whisper-cli -m ggml-medium.bin -l ko -ovtt -of video audio.wav
# → video.vtt
Whisper는 16kHz 모노를 기대하므로 ffmpeg로 먼저 맞춘다. 모델은 medium이 속도와 정확도의 균형이 좋았고, 잘 안 들리는 것은 large-v3로 다시 돌렸다. 5분짜리 영상이 M 시리즈 맥에서 1~2분 걸린다.
Whisper 출력의 버릇 — 그대로 올리면 안 되는 이유
나온 파일은 초안이다. 제작기법도 "소프트웨어 등에 의해 자동으로 생성된 자막은 정확도가 낮을 수 있으며 검토 및 보정 과정을 거쳐 제공해야 한다"고 따로 적어뒀다. 정확도 문제 말고도 형식적으로 자막답지 않은 부분이 있다.
큐가 너무 길다. 한 큐에 100자 넘게 들어오는 경우가 흔하다. 자막은 한 번에 두 줄, 한 줄에 한글 20자 안팎이 읽기 한계다. 그 이상은 화면 아래를 덮고, 읽기도 전에 사라진다. 긴 큐는 시각을 나눠 쪼개야 한다.
큐 사이에 틈이 없다. 앞 큐의 끝 시각이 다음 큐의 시작 시각과 정확히 같다. 사람이 만든 자막은 문장 사이에 짧은 틈을 둬서 "새 문장"임을 알려주는데, Whisper는 이어 붙인다. 뉴스처럼 말이 빽빽하면 그냥 둬도 되지만, 대사 사이에 쉼이 있는 영상은 손봐야 한다.
텍스트 앞에 공백이 하나 붙는다. 토큰 경계 때문에 모든 큐 텍스트가 " 입학 안내는"처럼 시작한다. 브라우저 자막 표시에는 영향이 없지만, 뒤에서 대본을 만들 때 이 공백이 그대로 딸려 온다. 한 번에 정리한다.
무음 구간에서 환청을 듣는다. 배경음악만 흐르는 구간에 "시청해 주셔서 감사합니다" 같은 문장이 튀어나온다. 학습 데이터의 흔적이다. 영상 앞뒤의 음악 구간은 반드시 눈으로 확인한다.
이 네 가지는 규칙이 분명해서 스크립트로 반쯤 걸러진다. 남는 것은 고유명사다. 기관명, 사람 이름, 전문 용어는 거의 항상 틀린다. 이건 사람이 봐야 한다.
그리고 음성 인식이 아예 만들어주지 않는 것이 있다. 제작기법은 자막에 "대화, 화자 식별, 음향 효과 등 모든 청각적 요소"를 담으라고 하고, 오류 사례로 "화면 밖에서 말하는 사람이 있는데 자막에 화자 정보가 없어 누가 말하는지 알기 어려운" 경우를 든다. Whisper는 말만 받아 적는다. 화자가 바뀌는 인터뷰 영상이라면 화자를, 음악이나 효과음이 의미를 가지면 그것을 손으로 넣어야 한다. WebVTT에 그 자리가 있다.
00:00:12.000 --> 00:00:15.200
<v 진행자>오늘 오신 분을 소개하겠습니다.
00:00:15.200 --> 00:00:17.000
[박수]
00:00:17.000 --> 00:00:20.500
<v 참가자 A>안녕하세요, 반갑습니다.
<v 이름>은 화자 태그다. 브라우저 자막에는 이름이 표시되지 않고 ::cue(v[voice="진행자"])로 화자별 스타일만 줄 수 있는데, 대본으로 펼칠 때는 cue.text에 태그가 그대로 들어 있으니 이름을 꺼내 쓸 수 있다. 음향은 관례대로 대괄호로 적는다.
노래는 다르다 — 반주에 묻힌 가사
교가 영상이 제일 애를 먹였다. 반주가 보컬보다 크고, 노래는 말과 달리 음절이 늘어지니 medium도 large-v3도 절반을 못 알아들었다.
결국 파이프라인이 길어졌다.
# 1. 보컬만 분리 (demucs)
demucs -n htdemucs audio.wav
# → separated/htdemucs/audio/vocals.wav
# 2. 16kHz 모노 + 음량 정규화
ffmpeg -i separated/htdemucs/audio/vocals.wav -ar 16000 -ac 1 \
-af "loudnorm=I=-16" vocals.wav
# 3. 가사를 힌트로 주고, 문맥 이어붙이기는 끄고
whisper-cli -m ggml-large-v3.bin -l ko \
--prompt "(가사 첫 소절을 그대로)" \
--no-context --suppress-nst -et 2.8 \
-ovtt -of anthem vocals.wav
세 가지가 효과가 있었다.
demucs로 반주를 걷어내는 것. 이게 제일 컸다. 보컬만 남기면 노래도 "느린 말"이 된다.--prompt에 가사를 넣는 것. Whisper는 프롬프트를 "직전에 이런 말이 있었다"는 문맥으로 쓴다. 가사를 넣어두면 그 어휘 쪽으로 기운다.--no-context. 기본값은 앞 구간의 인식 결과를 다음 구간의 문맥으로 넘기는데, 노래에서는 한 번 틀리면 그 오류가 끝까지 전파됐다. 끊는 편이 나았다.
그런데 여기서 솔직해질 필요가 있다. 가사를 이미 알고 있다면 음성 인식이 하는 일은 "무슨 말인지"가 아니라 "언제 나오는지"뿐이다. 결국 가사는 원문을 붙여 넣고, Whisper 출력에서는 시각만 가져왔다. 노래 자막은 인식이 아니라 정렬 문제다.
웹에 붙이기
track 요소
<video controls src="/video/intro.mp4">
<track kind="captions" src="/video/intro.vtt"
srclang="ko" label="한국어" default>
</video>
이게 전부다. controls가 있으면 브라우저가 자막 버튼을 그려주고, default가 있으면 처음부터 켜진다.
kind는 captions로 둔다. subtitles는 "다른 언어로 번역한 것"이라는 뜻이고, captions는 "소리를 못 듣는 사람을 위한 것"이라 효과음·화자 표시까지 포함하는 개념이다. 접근성 목적이면 captions다.
함정: 자막 파일은 페이지와 같은 출처에 둔다. 영상 파일은 CDN이나 스트리밍 서버에 있어도 되지만, <track>은 다른 출처에서 불러오려면 <video crossorigin>과 서버의 CORS 헤더가 모두 필요하다. 하나라도 빠지면 아무 오류 없이 자막이 안 나온다. 영상은 스트리밍 서버에, VTT는 홈페이지 서버에 두는 것이 가장 덜 번거로웠다.
자막 모양은 ::cue 로
video::cue {
font-size: 1.1rem;
background: rgba(0, 0, 0, 0.75);
color: #fff;
}
::cue 의사 요소로 자막 상자의 글꼴·색·배경을 바꿀 수 있다. 다만 쓸 수 있는 속성이 제한적이다(색, 배경, 글꼴, 줄바꿈 정도). 위치나 여백은 CSS가 아니라 VTT 큐 설정(line:, position:)으로 준다. 브라우저마다 렌더링이 달라서, 완전한 통제가 필요하면 자막을 직접 그려야 하는데 그 이야기는 뒤의 대본 절과 이어진다.
소리 없는 영상의 자막은 무엇을 담나
메인 화면의 배경 영상은 muted autoplay다. 소리가 안 나니 "말을 받아 적은 자막"은 아무도 못 듣는 소리를 받아 적은 것이 된다. 그런데 이 영상에는 화면에 구워진 문구가 있었다. 큰 글씨로 슬로건이 지나가는 식이다.
영상 안에 그려진 글자는 접근성 관점에서 이미지 속 글자와 같다. 스크린 리더가 못 읽고, 확대도 안 된다. 지침이 말한 "대사 없이 영상만 제공하는 경우, 화면해설(텍스트, 오디오, 대본)을 제공해야 한다"가 정확히 이 상황이다. 그래서 이 영상에는 음성 자막 대신 화면 문구를 시각에 맞춰 적은 별도의 트랙을 붙였다. 텍스트 형식의 화면해설이다.
<video muted autoplay playsinline loop src="/video/hero.mp4">
<track kind="captions" src="/video/hero_screen.vtt"
srclang="ko" label="화면 텍스트" default>
</video>
<button type="button" class="captionToggle" aria-pressed="true">자막 끄기</button>
같은 영상에 음성 자막과 화면 텍스트 자막이 둘 다 있을 수 있다. 어느 쪽을 default로 두느냐는 그 자리에서 사용자가 실제로 경험하는 것이 무엇이냐로 정한다. 음소거 배경 영상이면 화면 텍스트다.
controls가 없는 영상은 브라우저 자막 버튼도 없으므로 켜고 끄는 버튼을 따로 둔다. aria-pressed로 상태를 알리고, 클릭하면 track.track.mode를 'showing'과 'hidden' 사이에서 바꾼다.
대본은 자막 파일에서 만든다
여기가 이 글에서 제일 하고 싶었던 이야기다. 지침이 대본에 붙인 조건은 "재생되는 과정에서 제공"이었다. 영상 아래에 텍스트를 붙여둔 것이 걸린 이유다. 그런데 자막 파일이 있으면 재생을 따라가는 대본이 스크립트 몇 줄로 나온다. 브라우저가 VTT를 파싱해서 TextTrack 객체로 들고 있기 때문에, 그 큐 목록을 읽어 문단으로 뿌리고 재생 위치에 맞춰 강조하면 된다.
<video controls src="/video/intro.mp4">
<track kind="captions" src="/video/intro.vtt" srclang="ko" label="한국어" default>
</video>
<button type="button" class="transcriptToggle" aria-expanded="false">대본 보기</button>
<div class="videoTranscript" hidden></div>
document.querySelectorAll('.videoTranscript').forEach(function (box) {
// 가장 가까운 video 를 짝으로 — 한 페이지에 영상이 여럿이어도 된다
var scope = box.parentNode;
while (scope && !scope.querySelector('video')) scope = scope.parentNode;
var video = scope.querySelector('video');
var track = video.querySelector('track[kind="captions"]');
if (!track) return;
var tt = track.track;
tt.mode = 'showing'; // disabled 상태면 큐를 아예 안 불러온다
function build() {
if (!tt.cues || !tt.cues.length) return;
box.innerHTML = '';
Array.prototype.forEach.call(tt.cues, function (cue) {
var p = document.createElement('p');
cue.text.split('\n').forEach(function (line, i) {
if (i) p.appendChild(document.createElement('br'));
p.appendChild(document.createTextNode(line));
});
p.addEventListener('click', function () { video.currentTime = cue.startTime; });
cue.line_el = p;
box.appendChild(p);
});
}
track.addEventListener('load', build); // 아직 안 받아왔으면 받은 뒤에
build(); // 이미 캐시에 있으면 지금
tt.addEventListener('cuechange', function () {
box.querySelectorAll('.on').forEach(function (el) { el.classList.remove('on'); });
Array.prototype.forEach.call(tt.activeCues, function (cue, i) {
if (!cue.line_el) return;
cue.line_el.classList.add('on');
if (i === 0 && !box.hidden) {
box.scrollTop = cue.line_el.offsetTop - box.offsetTop
- (box.clientHeight - cue.line_el.offsetHeight) / 2;
}
});
});
});
이걸로 얻는 것이 셋인데, 앞에서 인용한 제작기법의 대본 요건과 하나씩 맞물린다.
- 대본 — 큐 텍스트가 문단으로 전부 펼쳐진다. 스크린 리더가 읽고, 페이지 검색에 잡히고, 복사할 수 있다. 영상 바로 아래에 있으니 "콘텐츠 근처에 배치"다.
- 현재 위치 강조 —
cuechange마다 지금 나오는 문장에.on이 붙고, 그 줄이 상자 가운데로 온다. "현재 재생 중인 부분을 식별할 수 있도록 표시"와 "재생 위치가 화면 내에서 유지되도록 자동 스크롤"이 이 몇 줄이다. - 클릭하면 이동 — 문장을 누르면
currentTime이 그 큐의 시작으로 간다. 긴 영상에서 원하는 대목을 찾는 데 이만한 것이 없다.
자막 파일을 손본 뒤에 대본을 따로 고칠 일도 없다. 원천이 하나라서다.
코드에서 세 군데를 짚어둔다.
tt.mode = 'showing'이 먼저다. 트랙 모드가 disabled면 브라우저는 VTT 파일을 받아오지도 않는다. cues가 null이다. default 속성이 있으면 이미 showing이지만, 없는 경우를 대비해 명시한다. 자막을 화면에 그리지 않고 대본만 쓰고 싶으면 'hidden'으로 두면 된다 — 받아오긴 하되 그리지 않는다.
load 이벤트와 즉시 호출을 둘 다 한다. VTT가 아직 오는 중이면 load에서 만들고, 캐시에 있어서 이미 파싱이 끝났으면 load가 다시 안 오므로 지금 만든다. 하나만 두면 새로고침 여부에 따라 대본이 비는 경우가 생긴다.
자동 스크롤은 scrollIntoView를 쓰지 않는다. 처음엔 썼다가 뺐다. scrollIntoView는 스크롤 가능한 조상을 전부 거슬러 올라가며 움직여서, 대본 상자 안의 줄을 맞추려다 페이지 전체가 영상 쪽으로 튄다. 상자의 scrollTop만 직접 계산해서 넣으면 상자 안에서만 움직인다. 페이징 글에서 겪은 것과 같은 함정이다.
자막을 직접 그리고 싶다면
앞서 ::cue로는 위치를 못 잡는다고 했다. 대본 코드에 답이 있다. tt.mode = 'hidden'으로 두고 cuechange에서 activeCues의 텍스트를 영상 위에 겹친 <div>에 써넣으면, 자막을 보통 HTML 요소로 그리는 것이 된다. 글꼴·위치·애니메이션 전부 CSS로 통제된다. 브라우저 기본 자막이 마음에 안 들 때 VTT를 버리지 않고 해결하는 길이다.
남는 것
음성 인식은 초안이다. 고유명사와 숫자는 거의 항상 사람이 고쳐야 한다. 자막을 붙였다는 것과 자막이 맞다는 것은 다르다. 200개를 붙이는 데 걸린 시간의 대부분은 인식이 아니라 검수였다.
화면 해설은 별개다. 자막은 소리를 글로 옮긴 것이고, 시각 장애 사용자를 위해 화면을 말로 설명하는 것(kind="descriptions")은 다른 트랙이다. 제작기법에서는 권고 사항이고, 대사 없는 구간에 넣되 사용자가 켜고 끌 수 있는 방식을 권한다. 이번에는 다루지 않았다.
VTT는 정적 파일이다. 영상을 CMS에서 교체하면 자막도 같이 교체해야 한다. 영상 필드 옆에 자막 필드를 두고, 자막 없는 영상은 목록에서 표시되게 해두는 것이 유지 관리의 절반이다.
정리
- KWCAG 2.2 5.2.1은 자막·대본·수어 중 하나. 제작기법은 자막도 대본도 동기화를 요구한다 — 대본은 근처 배치·현재 위치 표시·자동 스크롤. 영상 밑에 붙인 정적 텍스트는 대본이 아니다. 자막 파일 하나로 둘 다 나온다.
- 형식은 WebVTT. SRT와 비슷하지만 마침표·헤더가 다르다.
- 초안은 whisper.cpp
-ovtt. 긴 큐 쪼개기, 앞 공백 제거, 무음 구간 환청 확인, 고유명사 검수. 화자(<v>)와 음향([…])은 사람이 넣는다. - 노래는
demucs로 보컬 분리 후--prompt·--no-context. 가사를 알면 인식이 아니라 정렬 문제다. <track kind="captions">. VTT는 페이지와 같은 출처에.- 음소거 영상에는 화면 문구 트랙을. "대사 없이 영상만 제공하는 경우"의 화면해설이 이것이다.
- 대본은
TextTrackAPI로.mode를 먼저 켜고,load와 즉시 호출을 같이 두고, 스크롤은 직접 계산.
참고: KS X OT0003:2022 한국형 웹 콘텐츠 접근성 지침 2.2 · 웹 접근성을 고려한 콘텐츠 제작기법 2.2 — 자막 제공