대화형 비디오 자막
개요
자막은 독립된 레이어입니다. 자막은 모든 발화 단어를 전달하며, 프레임 안에 그려진 내용에 의존하지 않습니다. 프레임 안에 있는 사물의 레이블(부품 이름, 장단점, 화살표)은 완전히 다른 시각 언어(사물 옆에 콜아웃)를 가진 다른 레이어입니다. "이미 화면에 나와 있다"는 이유로 자막에서 단어를 잘라내지 마세요.
파이프라인: words.json + forbid.json (자막이 덮지 말아야 할 박스) + config.json + 프로필
→ plan → manifest.json (정규 데이터) → burn (베이크된 프리뷰 = 시각적 진실) / export srt|ass|vtt → validate.
형식 역할 (혼동하지 마세요):
| 형식 | 포함 | 포함하지 않음 |
|---|---|---|
manifest.json | 단어, 타이밍, 레이아웃, placement, 허용된 겹침, 리프트 사유, 렌더링 검사: 데이터의 진실 공급원 | 픽셀 |
.srt | 텍스트 + 타이밍 | 두께, 색상, 외곽선, 활성 단어, 위치. SRT에 절대 스타일을 약속하지 마세요 |
.ass | 패밀리 + Bold/\b700, libass 메트릭으로 변환된 크기, 외곽선, 백킹 박스, 시간 조각별 활성 단어, 위치 | 페이드; 폰트 필요: subtitles=x.ass:fontsdir=fonts |
.vtt | ::cue CSS (패밀리, 두께, text-shadow를 통한 외곽선, 백킹) + 시간 조각별 클래스 .active_<spk> / .em_<spk> / .dim / .u | 정확한 위치; 페이지가 @font-face로 폰트를 로드해야 함 |
베이크된 preview.mp4 | 모든 것: 유일한 시각적 진실 공급원 | — |
사용 시기
- 다이얼로그 스케치, 인터뷰, 내레이터가 있는 설명 영상: 세로 또는 정사각형, 최대 1~2줄.
- 단어 타이밍이 있거나 (또는 오디오: env 키를 통한 선택적 클라우드 STT) 얼굴/손/주요 소품의 위치(박스로 표시; 3D 장면은 단색 매트를 렌더링하고 박싱)를 알고 있을 때.
- 가사 비디오에는 적합하지 않음 (바운스, 스케일링 단어를 의도적으로 지원하지 않음).
알고리즘
세분화 (chunk in the profile): pause_s(0.35초)보다 긴 일시정지, 문장 끝, 화자 변경 시 하드 브레이크; 청크에 2단어 이상 있으면 쉼표에서 소프트 브레이크; max_words, 픽셀 너비 (안전 영역의 max_width_frac), max_dur_s(약 1.2초 음성이 잘 읽힘)로 제한. 청크는 기능어(не, в, а, and, the…)로 끝나지 않음: 다음 청크로 이동. 외톨이 단어는 같은 문장의 이웃 청크에 들어갈 수 있으면 합류. 단어 지속 시간은 중앙값의 2.5배로 클램프되어 일시정지까지 걸리지 않음. 타이밍: 유닛은 첫 단어가 시작되기 ≤ 50ms 전에 나타나며, 이전 단어를 절대 자르지 않고, 마지막 단어 이후 hold_s 만큼 유지되며, 최소 min_show_s 이상 지속.
배치 — placement: {mode: fixed_bottom_center, collision_lift: false} (대화 기본값).
- 전체 비디오에 대해 하나의 기준선:
baseline = safe.bottom - baseline_margin_px(또는 명시적으로 설정). 모든 유닛은 이 기준선에 하단 정렬 (y + h = baseline)되고 프레임에 중앙 정렬 (x + w/2 = W/2). 어떤 것도 움직이지 않음: 리프트 없음, 측면 이동 없음, 화자별 x 없음. 줄 너비는 중앙 정렬된 줄이 안전 영역 안에 머물도록 제한. - 기준선에서 금지 박스를 교차하는 줄은 그대로 유지: 유닛은
overlap_accepted: [classes]를 받고 QA 시트는 노란색 프레임 "겹침 허용 (고정 기준선)"을 그림. 정보이지 실패가 아님. 플랫폼 UI와 교차하는 것은 여전히 오류 (폰트를 줄이거나 구문을 분할). - 옵션
collision_lift: true(모드baseline_lift): 화자 아래 x (align: speaker) 또는 중앙 정렬 후 왼쪽/오른쪽 폴백; 모든 x가 충돌하면 전체lift_step_px단계만큼 리프트하여 빈 공간 확보. 유닛은lift = {px, reason: [기준선에서 충돌한 클래스], t0, t1}를 받고, manifest는 모든 리프트를collision_lifts에 나열. 사유 없음 → 리프트 없음. 요청이 어떤 대가를 치르더라도 얼굴을 가리지 말라고 할 때만 사용. - 절대 단어를 드롭하지 않음.
시각 스타일. 외곽선(outline_px) + 부드러운 그림자 + 밝은 백킹(백킹의 알파는 유닛당 뒤의 90번째 백분위 픽셀에 대해 가장 어두운 lit 색상 ≥ 4.5:1 (WCAG)을 유지하는 데 필요한 만큼만 증가). 화자는 활성 단어 색상과, 하이브리드/카드 모드에서 화자 색상의 4px 막대로 구분됨; 말풍선, 무거운 네임 플레이트 없음. 차분한 움직임: 알파 페이드만, 스케일 팝, 수직 슬라이드 없음.
QA (scripts/validate.py, demo에 의해 실행됨):
- 스키마: manifest를
templates/manifest.schema.json과 대조; - 줄 ≤ 2;
- 배치:
fixed_bottom_center에서 모든 유닛의 y-기준선 및 x-중심의 분산은 0 (0.5px 허용 오차)이고 리프트 없음;baseline_lift에서 모든 리프트는 그리드 위에 있고, 사유가 있으며, 유닛이 기준선에서 실제로 충돌함; - 모든 프레임에서 플랫폼 UI와의 겹침 0; 금지 박스:
baseline_lift에서 0,fixed_bottom_center에서overlap_accepted(정보)로 보고; - 모든 단어가 정확히 한 번, 순서대로, 말하는 동안 보임;
- 대비 ≥ 4.5 및 바운스 없음, 둘 다
burn에 의해 프레임별로 측정; - 폰트 해석: 파일 존재, Pillow 패밀리 및 스타일, OS/2 가중치,
fc-scan, libassfontselect가 선언된 파일과 가중치를 선택; - 픽셀: 베이크된 프레임과 ASS의 libass 렌더링 모두에서 활성 색상이 존재하고, 글리프 하단이 레이아웃의 3px 이내, 획 너비가 선언된 가중치의 참조 래스터의 1px 이내, 외곽선이
outline_px의 1px 이내.
프로필 (profiles/*.json)
| 프로필 | 설명 | 최적 사용처 |
|---|---|---|
karaoke | 한 번에 2~4단어, 말하는 단어는 화자 색상, 나머지는 흐리게 | 가장 안정적; 고정 레인, 설명 영상 |
reveal | 단어가 말할 때 페이드 인; 레이아웃은 미리 고정; ≤ 2줄 | 차분한 내레이션 |
cards | 카드당 하나의 생각 (≤ 8단어, 2줄), 조밀한 패널, 키워드 색상 | 느리고 인용하기 좋은 대사; 말보다 먼저 읽힘 |
kinetic | 한 번에 하나의 토큰, 더 큰 폰트 | 펀치라인에만; 맥락 부족 |
hybrid (기본값) | ≤ 3단어, 단어 페이드 인, 말하는 단어 아래 밑줄, 강조 색상, 화자 바 | 대화 |
R&D 시스템의 옵션을 매개변수로: 템포 = chunk.max_dur_s: 1.2 + timing.min_show_s: 0.83 (hybrid에서 켜짐);
스트레스 = emphasis: "energy" (--audio에서 화자별 단어 음량 z-점수), 또는 keywords 목록과 함께 "keyword".
명령어
python -m subkit demo examples/fixture # 5 profile + hybrid_lift -> previews, srt/ass/vtt, validation_report.{md,json}
python -m subkit plan --words W --forbid F --config C [--profile hybrid] [--audio A] -o manifest.json
python -m subkit burn manifest.json --forbid F --video in.mp4 [--audio A] -o preview.mp4 --qa qa.jpg
python -m subkit export manifest.json --format ass -o captions.ass # srt | ass | vtt
python scripts/check_clean.py # commit 전: 미디어/키/절대 경로 없음
ELEVENLABS_API_KEY=... python -m subkit stt audio.wav -o words.json # 선택사항; 또는 --provider groq with GROQ_API_KEY
함정
- libass ≠ Pillow 폰트 크기. libass는 OS/2 winAscent+winDescent로 크기를 정하고 기준선을 줄 상단 아래 winAscent에 배치합니다; export는 둘 다 변환하거나, 그렇지 않으면 ASS 텍스트가 약 35% 더 작고, 얇고, 낮게 나옵니다.
- ASS의 가변 폰트는 기본 인스턴스(보통 Regular)로 폴백됩니다. 사용하는 가중치의 정적 인스턴스(여기서는
Onest-Bold.ttf, 700)를 제공하고fontsdir를 전달하세요. ffmpeg -ss를-i앞에 사용하면 타임스탬프가 재설정됩니다: 서브타이틀 필터가 t=0을 렌더링합니다. ASS를 구울 때는-i뒤에 Seek 하세요.\k/\kf가라오케는 센티초 단위이며 모든 말한 단어를 영구적으로 색칠합니다. "현재 단어만"을 위해서는 (export가 하는 것처럼) 시간 조각을 방출하세요.- SRT에는 스타일이 없습니다: 결과물에 두께나 하이라이트가 필요하면 ASS/VTT 또는 베이크된 비디오를 제공하세요.
- 떠다니는 줄(화자별 x, 히스테리시스, "더 예뻐 보이는" 리프트)은 대화에서 지터로 읽힙니다. 기본 고정 모드를 유지하세요;
baseline_lift에서는 검증기가 각 리프트가 실제로 충돌하는지 다시 확인합니다. - 대비는 배경의 평균이 아닌, 가장 밝은 10% 픽셀에 대해 lit 색상으로 판단합니다: 불, 눈, 하이라이트는 평균에 실패합니다.
- 이미 화면에 그려진 단어를 드롭하지 마세요. 대신 그려진 레이블을 주석 레이어로 옮기세요.
templates/safe_zones.json의 안전 영역은 근사치입니다; 플랫폼 UI가 변경되므로 게시 전에 다시 확인하세요.
체크리스트
- words.json이 전체 음성을 커버 (클라우드 STT 또는 강제 정렬기); 화자 레이블링
- forbid.json: 샷별 얼굴, 손, 불/소품 (+
align: speaker용speakers박스) - config: 크기, fps,
safe_zone, 화자 색상 (다른 색조, 둘 다 백킹에서 ≥ 4.5:1) -
plan→ manifest;overlap_accepted(고정 모드) 또는collision_lifts(리프트 모드, 각각 실제 사유 포함) 읽기 -
burn→ QA 시트(청록 = 기준선, 노랑 = 겹침 허용, 주황 = 사유 있는 리프트) 및 실제 프레임 확인 -
validate모두 PASS; 베이크된 비디오 제공 (+ 플레이어가 렌더링하는 경우 ASS/VTT, SRT는 일반 텍스트로만)