
영상작업을 할 때 필요한 게 영상에 자막을 넣는 일입니다.
VREW, 캡컷 등 여러 유료툴이 있는데 Whisper 를 이용하면 무료로 처리가 가능할 것 같아서 처리해 봤는데
생각보다 자막추출이 잘돼서 그 방법을 알아봅니다.
순서는 아래와 같습니다.
Whisper는 OpenAI에서 만든 음성 인식 AI 모델(STT)입니다.
특징
쉽게 말하면 음성을 텍스트로 바꿔주는 AI 엔진입니다.
영상에서 소리만 분류할 때 사용되는 패키지로 아래 명령으로 컴퓨터에 설치합니다.
winget install FFmpeg
설치 후 아래 명령으로 나온 위치를 복사하여 환경변수에 추가합니다.
where ffmpeg

환경변수 추가

아래 명령으로 설치 확인
ffmpeg -version

(저는 가상환경에서 설치하지 않고 로컬환경에 설치했습니다.)
pip install "customtkinter>=5.2.0" "openai-whisper>=20231117" "tkinterdnd2>=0.3.0"
customtkinter : tkinter 를 더 modern 한 스타일로 꾸며주는 GUI 라이브러리
openai-whisper : 음성 파일을 텍스트로 바꿔주는 음성 인식 라이브러리
tkinterdnd2 : tkinter나 customtkinter 앱에서 파일 드래그 앤 드롭 기능을 넣을 때 쓰는 패키지
아래 코드 실행해
C:\Users\junij>python -c "import sys, torch; print(sys.executable); print(torch.__version__); print(torch.version.cuda); print(torch.cuda.is_available())"
아래처럼 뜬다면 cpu 버전이 설치된 것이므로 재설치가 필요합니다.

개인 gpu 에 따라 cuda 버전이 다르니 확인 후 재설치해야 합니다.
제거
pip uninstall -y openai-whisper torch torchvision torchaudio
pip cache purge
설치
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
확인
python -c "import torch; print(torch.__version__); print(torch.version.cuda); print(torch.cuda.is_available()); print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'no gpu')"
결과가 아래와 같이 나오면 정상

whisper 가 gpu 인식을 제대로 하고 있는지 확인합니다.
python -c "import whisper; model = whisper.load_model('base', device='cuda'); print('whisper gpu ok')"
결과가 아래와 같다면 정상입니다.

전체 코드를 보기 전 핵심은 아래와 같습니다.
1. 영상에서 음성 분리
import subprocess
subprocess.run(
[ffmpeg_exe, "-y", "-i", video_path, "-ar", "16000", "-ac", "1", audio_path],
capture_output=True
)
2. 분리된 음성파일에서 음성 인식
import whisper
model = whisper.load_model("medium", device="cuda")
result = model.transcribe(audio_path)
3. srt 파일 생성
def to_srt(segments):
out = []
for i, seg in enumerate(segments, 1):
out.append(
f"{i}\n"
f"{format_ts(seg['start'])} --> {format_ts(seg['end'])}\n"
f"{seg['text'].strip()}\n"
)
return "\n".join(out)
(AI 주석 / UI 때문에 코드가 복잡함)
autosub.py
# =============================================================================
# AutoSub - 자동 자막 추출기
# =============================================================================
# 동작 흐름:
# 1. 사용자가 영상 파일을 드래그하거나 선택
# 2. FFmpeg 로 영상에서 오디오(WAV) 추출
# 3. OpenAI Whisper 모델로 음성 → 텍스트 변환 (STT)
# 4. 타임스탬프 포함 SRT 자막 파일 저장
#
# 필요 패키지:
# pip install customtkinter openai-whisper tkinterdnd2 torch
# =============================================================================
import customtkinter as ctk # 모던 다크 테마 UI 라이브러리 (tkinter 래퍼)
import tkinter as tk # 기본 tkinter (StringVar 등에 사용)
from tkinter import filedialog, messagebox # 파일 선택 대화상자, 메시지 팝업
import threading # Whisper 처리를 별도 스레드에서 실행 (UI 블로킹 방지)
import shutil # FFmpeg 실행 파일 PATH 검색에 사용
import os # os.startfile() - 폴더 탐색기 열기
import subprocess # FFmpeg 외부 프로세스 실행
from pathlib import Path # 파일 경로 조작 (크로스플랫폼)
import time # 로그 타임스탬프, 프로그레스 틱 딜레이
# ── customtkinter 전역 설정 ────────────────────────────────────────────────────
ctk.set_appearance_mode("dark") # 다크 모드 적용
ctk.set_default_color_theme("blue") # 기본 강조색 (버튼 등) - 이후 커스텀 색상으로 덮어씀
# ── 색상 팔레트 (CSS 변수처럼 전역으로 관리) ──────────────────────────────────
C_BG = "#0d0d14" # 앱 배경 (가장 어두운 계층)
C_SURFACE = "#14141f" # 카드 배경
C_SURFACE2 = "#1c1c2a" # 카드 내부 요소 배경 (드롭존, 입력 행 등)
C_BORDER = "#2a2a3d" # 테두리 색
C_ACCENT = "#c8ff00" # 주 강조색 (형광 연두) - 버튼, 퍼센트 텍스트
C_ACCENT2 = "#00d4ff" # 보조 강조색 (사이안) - 선택된 파일명, 저장 버튼
C_TEXT = "#e0e0ee" # 일반 텍스트
C_MUTED = "#5a5a78" # 보조 텍스트 (레이블, 힌트)
C_SUCCESS = "#00e676" # 성공 상태 (완료 메시지)
C_ERROR = "#ff4444" # 오류 상태
C_GPU = "#ff9f43" # GPU 감지 시 배너 색 (주황)
# ── 폰트 정의 (굴림 시스템 폰트, 크기·스타일별 상수화) ──────────────────────
# 튜플 형식: (폰트명, 크기) 또는 (폰트명, 크기, 스타일)
F_TITLE = ("Gulim", 36, "bold") # 앱 타이틀 "AUTOSUB"
F_SUB = ("Gulim", 10) # 타이틀 아래 서브텍스트
F_SECTION = ("Gulim", 10) # 섹션 레이블 (01, 02, 03...)
F_LABEL_SM = ("Gulim", 11) # 일반 레이블, 버튼
F_LABEL_XS = ("Gulim", 10) # 작은 보조 레이블
F_BTN = ("Gulim", 12, "bold") # 보조 버튼 (저장 폴더 열기)
F_BTN_BIG = ("Gulim", 14, "bold") # 주 실행 버튼 (자막 추출 시작)
F_MONO = ("Gulim", 10) # 로그 텍스트박스
F_PCT = ("Gulim", 18, "bold") # 진행률 퍼센트 표시
# =============================================================================
# FFmpeg 실행 파일 자동 탐색
# =============================================================================
def find_ffmpeg():
"""
FFmpeg 실행 파일 경로를 반환. 없으면 None.
탐색 순서:
1. 시스템 PATH에서 검색 (shutil.which)
2. Windows 일반 설치 경로들 순서대로 확인
"""
# 1순위: PATH에 등록된 ffmpeg 찾기
found = shutil.which("ffmpeg")
if found:
return found
# 2순위: Windows 일반 설치 위치 하드코딩 목록
for c in [
r"C:\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
r"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
str(Path.home() / "ffmpeg" / "bin" / "ffmpeg.exe"), # 홈 디렉토리
]:
if Path(c).exists():
return c
return None # 찾지 못한 경우
# =============================================================================
# GPU(CUDA) 감지
# =============================================================================
def detect_device():
"""
Whisper 추론에 사용할 장치를 감지하여 (device, 설명문자열) 튜플 반환.
Returns:
("cuda", "RTX 4080 (16384 MB VRAM)") - GPU 사용 가능 시
("cpu", "CUDA 미감지 - CPU 사용") - GPU 없을 때
("cpu", "torch 미설치") - torch 패키지 없을 때
"""
try:
import torch
if torch.cuda.is_available():
name = torch.cuda.get_device_name(0) # GPU 모델명 (예: NVIDIA GeForce RTX 4080)
vram = torch.cuda.get_device_properties(0).total_memory // (1024 ** 2) # VRAM MB 단위
return "cuda", f"{name} ({vram} MB VRAM)"
else:
return "cpu", "CUDA 미감지 - CPU 사용"
except ImportError:
# torch 자체가 설치되지 않은 경우
return "cpu", "torch 미설치"
# =============================================================================
# SRT 자막 형식 유틸리티
# =============================================================================
def format_ts(s):
"""
초(float)를 SRT 타임스탬프 문자열로 변환.
Args:
s: 초 단위 시간 (예: 65.4)
Returns:
"00:01:05,400" 형식 문자열
"""
h, m = int(s // 3600), int((s % 3600) // 60)
sec, ms = int(s % 60), int((s % 1) * 1000)
return f"{h:02}:{m:02}:{sec:02},{ms:03}"
def to_srt(segments):
"""
Whisper 변환 결과(segments 리스트)를 SRT 형식 문자열로 조합.
SRT 형식 예시:
1
00:00:01,200 --> 00:00:04,600
안녕하세요.
2
00:00:05,100 --> 00:00:08,300
반갑습니다.
Args:
segments: whisper 결과의 'segments' 키 값
[{"start": 1.2, "end": 4.6, "text": "안녕하세요."}, ...]
Returns:
완성된 SRT 문자열
"""
out = []
for i, seg in enumerate(segments, 1): # SRT 번호는 1부터 시작
out.append(
f"{i}\n"
f"{format_ts(seg['start'])} --> {format_ts(seg['end'])}\n"
f"{seg['text'].strip()}\n" # 앞뒤 공백 제거
)
return "\n".join(out)
# =============================================================================
# 드래그앤드롭 지원 베이스 클래스 설정
# =============================================================================
# customtkinter(CTk)와 tkinterdnd2를 함께 사용하려면 다중 상속 믹스인이 필요.
# tkinterdnd2의 DnDWrapper를 CTk와 함께 상속하고,
# _require()로 Tk 인스턴스에 DnD 기능을 주입함.
try:
from tkinterdnd2 import TkinterDnD, DND_FILES
class DnDCTk(ctk.CTk, TkinterDnD.DnDWrapper):
"""customtkinter + tkinterdnd2 통합 베이스 클래스"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Tk 인스턴스에 tkdnd 확장 라이브러리를 로드하고 버전 저장
self.TkdndVersion = TkinterDnD._require(self)
BaseClass = DnDCTk # DnD 가능한 베이스 사용
HAS_DND = True
except Exception:
# tkinterdnd2 미설치 시 일반 CTk 사용 (드래그앤드롭 없이 동작)
BaseClass = ctk.CTk
HAS_DND = False
# =============================================================================
# 메인 앱 클래스
# =============================================================================
class AutoSubApp(BaseClass):
"""
AutoSub 메인 윈도우.
레이아웃 구조:
CTkScrollableFrame (scroll)
├── 헤더 (AUTOSUB 타이틀)
├── FFmpeg 상태 배너
├── GPU 상태 배너
├── 01 영상 파일 선택 (드롭존 + 파일명 표시)
├── 02 저장 위치
├── 03 설정 (언어, 모델)
├── 자막 추출 시작 버튼
└── 04 진행 상황 (프로그레스바 + 로그 + 폴더 열기)
"""
def __init__(self):
super().__init__()
self.title("AutoSub - 자동 자막 추출기")
self.geometry("620x860") # 초기 창 크기 (너비x높이)
self.minsize(560, 780) # 최소 크기 제한
self.configure(fg_color=C_BG)
self.resizable(True, True) # 사용자가 크기 조절 가능
# ── 상태 변수 ──────────────────────────────────────────
self._video_path = "" # 선택된 영상 파일 절대 경로
self._output_dir = "" # SRT 저장 폴더 경로
self.language = tk.StringVar(value="ko") # 자막 언어 선택
self.model_var = tk.StringVar(value="medium") # Whisper 모델 선택
self.is_running = False # 변환 작업 중복 실행 방지 플래그
self.ffmpeg_path = None # find_ffmpeg() 결과 저장
self.whisper_device = "cpu" # detect_device() 결과 저장 ("cuda" or "cpu")
# ── 부드러운 프로그레스바 애니메이션 상태 ──────────────
self._progress_target = 0.0 # 목표 진행률 (0.0 ~ 1.0)
self._progress_current = 0.0 # 현재 렌더링 진행률 (이징으로 목표에 수렴)
self._progress_animating = False # 애니메이션 루프 실행 중 여부
self._build_ui() # UI 위젯 생성
self._check_deps() # FFmpeg/GPU 상태 확인 후 배너 업데이트
# =========================================================================
# 프로그레스바 부드러운 이징 애니메이션
# =========================================================================
def _animate_progress(self):
"""
30ms 간격으로 재귀 호출되며 현재값을 목표값으로 부드럽게 이동.
이징 공식: current += (target - current) * 0.08
- 차이가 클수록 빠르게, 가까울수록 느리게 이동 (EaseOut 효과)
- 차이가 0.002 미만이면 애니메이션 종료
"""
if not self._progress_animating:
return
diff = self._progress_target - self._progress_current
if abs(diff) < 0.002:
# 목표에 충분히 근접하면 정확히 맞추고 종료
self._progress_current = self._progress_target
self._progress_animating = False
else:
# EaseOut: 남은 거리의 8%씩 이동
self._progress_current += diff * 0.08
# UI 업데이트 (메인 스레드에서 호출되므로 안전)
self.progress.set(self._progress_current)
self.pct_label.configure(text=f"{int(self._progress_current * 100)}%")
# 아직 애니메이션 중이면 30ms 후 재호출
if self._progress_animating:
self.after(30, self._animate_progress)
def _set_progress(self, pct, msg):
"""
백그라운드 스레드에서 안전하게 진행률과 상태 메시지를 업데이트.
after(0, ...) 를 사용해 tkinter 메인 스레드 큐에 작업 예약.
Args:
pct: 목표 진행률 0~100 (int)
msg: 상태 메시지 문자열
"""
def _do():
self._progress_target = pct / 100 # 0~1 범위로 변환
if not self._progress_animating:
# 애니메이션이 멈춰있으면 새로 시작
self._progress_animating = True
self._animate_progress()
self.status_label.configure(text=msg, text_color=C_TEXT)
self.after(0, _do) # 메인 스레드에서 실행 예약
# =========================================================================
# UI 빌드
# =========================================================================
def _build_ui(self):
"""전체 UI 위젯을 생성하고 배치."""
# 루트 윈도우 그리드 설정: scroll 프레임이 전체 영역을 채우도록
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(0, weight=1)
# 스크롤 가능한 메인 컨테이너
scroll = ctk.CTkScrollableFrame(self, fg_color=C_BG, scrollbar_button_color=C_BORDER)
scroll.grid(row=0, column=0, sticky="nsew")
scroll.grid_columnconfigure(0, weight=1)
r = 0 # 현재 그리드 행 번호 (위에서 아래로 순서대로 배치)
# ── 헤더: "AUTO" + "SUB" 타이틀 ──────────────────────────────────────
hdr = ctk.CTkFrame(scroll, fg_color="transparent")
hdr.grid(row=r, column=0, sticky="ew", padx=30, pady=(30, 6))
hdr.grid_columnconfigure(0, weight=1); r += 1
# "AUTO"(형광 연두) + "SUB"(사이안)을 나란히 표시
tf = ctk.CTkFrame(hdr, fg_color="transparent")
tf.grid(row=0, column=0)
ctk.CTkLabel(tf, text="AUTO", font=F_TITLE, text_color=C_ACCENT).grid(row=0, column=0)
ctk.CTkLabel(tf, text="SUB", font=F_TITLE, text_color=C_ACCENT2).grid(row=0, column=1)
ctk.CTkLabel(hdr, text="Whisper 자동 자막 추출기", font=F_SUB, text_color=C_MUTED).grid(row=1, column=0, pady=(2, 0))
# ── 상태 배너 (FFmpeg / GPU) ──────────────────────────────────────────
# _make_banner()로 생성 후 프레임과 레이블을 분리 저장
# → _check_deps()에서 색상과 텍스트를 동적으로 업데이트
self.ffmpeg_banner, self.ffmpeg_lbl = self._make_banner(scroll, r, "FFmpeg 확인 중...", "#ffcc44", "#1a1200", "#5a4400"); r += 1
self.gpu_banner, self.gpu_lbl = self._make_banner(scroll, r, "GPU 확인 중...", "#ffcc44", "#1a1200", "#5a4400"); r += 1
# ── 01 영상 파일 선택 ─────────────────────────────────────────────────
r = self._section(scroll, r, "01 영상 파일 선택")
fc = self._card(scroll, r); fc.grid_columnconfigure(0, weight=1); r += 1
# 드롭존: 고정 높이 120px, 내부에 안내 텍스트와 파일 선택 버튼
self.dropzone = ctk.CTkFrame(fc, fg_color=C_SURFACE2, corner_radius=10,
border_width=2, border_color=C_BORDER, height=120)
self.dropzone.grid(row=0, column=0, sticky="ew", pady=(0, 10))
self.dropzone.grid_columnconfigure(0, weight=1)
self.dropzone.grid_propagate(False) # height=120 고정 (자식 크기에 맞춰 변하지 않음)
# 드롭존 내부 위젯을 place()로 중앙 배치
dz = ctk.CTkFrame(self.dropzone, fg_color="transparent")
dz.place(relx=0.5, rely=0.5, anchor="center") # 정중앙
ctk.CTkLabel(dz, text="영상 파일을 드래그하거나", font=F_LABEL_SM, text_color=C_TEXT).grid(row=0, column=0, pady=(0, 6))
ctk.CTkButton(dz, text="파일 선택", font=F_LABEL_SM,
fg_color=C_SURFACE, hover_color=C_BORDER,
text_color=C_ACCENT2, border_width=1, border_color=C_ACCENT2,
corner_radius=6, height=32, width=110,
command=self._browse_video).grid(row=1, column=0)
# tkinterdnd2가 설치된 경우에만 드롭존에 DnD 이벤트 등록
if HAS_DND:
self.dropzone.drop_target_register(DND_FILES) # 파일 드롭 대상으로 등록
self.dropzone.dnd_bind("<<Drop>>", self._on_drop) # 드롭 이벤트 핸들러 연결
# 선택된 파일명 표시 행
fr = ctk.CTkFrame(fc, fg_color=C_SURFACE2, corner_radius=8)
fr.grid(row=1, column=0, sticky="ew"); fr.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(fr, text="파일:", font=F_LABEL_SM, text_color=C_MUTED).grid(row=0, column=0, padx=(12, 6), pady=10)
self.file_label = ctk.CTkLabel(fr, text="선택되지 않음", font=F_LABEL_SM,
text_color=C_MUTED, anchor="w", wraplength=420)
self.file_label.grid(row=0, column=1, sticky="w", pady=10)
# ── 02 저장 위치 ──────────────────────────────────────────────────────
r = self._section(scroll, r, "02 저장 위치")
oc = self._card(scroll, r); oc.grid_columnconfigure(0, weight=1); r += 1
or_ = ctk.CTkFrame(oc, fg_color=C_SURFACE2, corner_radius=8)
or_.grid(row=0, column=0, sticky="ew"); or_.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(or_, text="저장:", font=F_LABEL_SM, text_color=C_MUTED).grid(row=0, column=0, padx=(12, 6), pady=10)
self.out_label = ctk.CTkLabel(or_, text="", font=F_LABEL_SM,
text_color=C_MUTED, anchor="w", wraplength=320)
self.out_label.grid(row=0, column=1, sticky="w", pady=10)
ctk.CTkButton(or_, text="변경", font=F_LABEL_SM, width=60, height=28,
fg_color=C_SURFACE, hover_color=C_BORDER, text_color=C_TEXT,
border_width=1, border_color=C_BORDER, corner_radius=6,
command=self._browse_output).grid(row=0, column=2, padx=10, pady=8)
# ── 03 설정 (언어 / 모델) ─────────────────────────────────────────────
r = self._section(scroll, r, "03 설정")
cc = self._card(scroll, r); cc.grid_columnconfigure((0, 1), weight=1); r += 1
# 레이블
ctk.CTkLabel(cc, text="언어", font=F_LABEL_XS, text_color=C_MUTED).grid(row=0, column=0, sticky="w", pady=(0, 4))
ctk.CTkLabel(cc, text="Whisper 모델", font=F_LABEL_XS, text_color=C_MUTED).grid(row=0, column=1, sticky="w", pady=(0, 4), padx=(10, 0))
# OptionMenu 공통 스타일 딕셔너리 (중복 제거)
om = dict(fg_color=C_SURFACE2, button_color=C_BORDER, button_hover_color=C_ACCENT,
dropdown_fg_color=C_SURFACE2, font=F_LABEL_SM, dropdown_font=F_LABEL_SM,
text_color=C_TEXT, dropdown_text_color=C_TEXT, corner_radius=8)
# 언어 선택 드롭다운
ctk.CTkOptionMenu(cc, variable=self.language,
values=["ko", "en", "ja", "zh", "es", "fr", "auto"], **om
).grid(row=1, column=0, sticky="ew", pady=(0, 10))
# 모델 선택 드롭다운 (tiny=빠름/낮은정확도 ~ large=느림/높은정확도)
ctk.CTkOptionMenu(cc, variable=self.model_var,
values=["tiny", "base", "small", "medium", "large"], **om
).grid(row=1, column=1, sticky="ew", pady=(0, 10), padx=(10, 0))
# 모델별 특성 힌트 표시
hint = ctk.CTkFrame(cc, fg_color=C_SURFACE2, corner_radius=8)
hint.grid(row=2, column=0, columnspan=2, sticky="ew")
ctk.CTkLabel(hint, text="tiny/base=빠름 small=균형 medium=한국어 권장 large=최고 정확도",
font=F_LABEL_XS, text_color=C_MUTED).grid(padx=12, pady=8)
# ── 실행 버튼 ─────────────────────────────────────────────────────────
self.run_btn = ctk.CTkButton(scroll, text="자막 추출 시작", font=F_BTN_BIG,
fg_color=C_ACCENT, hover_color="#a8d900",
text_color=C_BG, # 밝은 배경에 어두운 텍스트
corner_radius=10, height=52, command=self._start)
self.run_btn.grid(row=r, column=0, sticky="ew", padx=30, pady=(10, 10)); r += 1
# ── 04 진행 상황 ──────────────────────────────────────────────────────
r = self._section(scroll, r, "04 진행 상황")
pc = self._card(scroll, r); pc.grid_columnconfigure(0, weight=1); r += 1
# 상태 메시지 + 퍼센트 (좌우 배치)
pt = ctk.CTkFrame(pc, fg_color="transparent")
pt.grid(row=0, column=0, sticky="ew", pady=(0, 6)); pt.grid_columnconfigure(0, weight=1)
self.status_label = ctk.CTkLabel(pt, text="대기 중...", font=F_LABEL_SM,
text_color=C_MUTED, anchor="w")
self.status_label.grid(row=0, column=0, sticky="w")
self.pct_label = ctk.CTkLabel(pt, text="0%", font=F_PCT, text_color=C_ACCENT)
self.pct_label.grid(row=0, column=1, sticky="e")
# 프로그레스바 (0.0 ~ 1.0)
self.progress = ctk.CTkProgressBar(pc, mode="determinate",
fg_color=C_BORDER, progress_color=C_ACCENT2,
corner_radius=4, height=8)
self.progress.set(0)
self.progress.grid(row=1, column=0, sticky="ew")
# 로그 텍스트박스 (읽기 전용, 스크롤 가능)
self.log_box = ctk.CTkTextbox(pc, height=150, fg_color=C_SURFACE2,
text_color="#88aacc", # 연한 파란색 모노 텍스트
font=F_MONO, corner_radius=8,
border_width=1, border_color=C_BORDER)
self.log_box.grid(row=2, column=0, sticky="ew", pady=(10, 0))
self.log_box.configure(state="disabled") # 초기에는 수정 불가
# 완료 후 활성화되는 "저장 폴더 열기" 버튼
self.open_btn = ctk.CTkButton(pc, text="저장 폴더 열기", font=F_BTN,
fg_color=C_SURFACE2, hover_color=C_BORDER,
text_color=C_ACCENT2, border_width=1, border_color=C_ACCENT2,
corner_radius=8, height=40,
command=self._open_output_dir,
state="disabled") # 변환 완료 전까지 비활성
self.open_btn.grid(row=3, column=0, sticky="ew", pady=(10, 0))
# 스크롤 하단 여백
ctk.CTkFrame(scroll, fg_color="transparent", height=24).grid(row=r, column=0)
# ── 초기값 설정 ───────────────────────────────────────────────────────
# 기본 저장 경로: 바탕화면 (없으면 홈 디렉토리)
default_out = Path.home() / "Desktop"
if not default_out.exists():
default_out = Path.home()
self._output_dir = str(default_out)
self.out_label.configure(text=self._output_dir)
# ── UI 헬퍼 메서드 ────────────────────────────────────────────────────────
def _make_banner(self, parent, row, text, fg_text, fg_bg, border_col):
"""
상태 배너 프레임 + 레이블 생성.
Returns:
(CTkFrame, CTkLabel) - 나중에 색상·텍스트 업데이트용으로 저장
"""
f = ctk.CTkFrame(parent, fg_color=fg_bg, corner_radius=10,
border_width=1, border_color=border_col)
f.grid(row=row, column=0, sticky="ew", padx=30, pady=(4, 2))
f.grid_columnconfigure(0, weight=1)
lbl = ctk.CTkLabel(f, text=text, font=F_LABEL_XS, text_color=fg_text,
wraplength=520, justify="left")
lbl.grid(padx=14, pady=9, sticky="w")
return f, lbl
def _section(self, parent, row, text):
"""
섹션 구분선 + 레이블 생성.
"01 영상 파일 선택 ─────────────────" 형태의 행.
Returns:
row + 1 (다음 위젯이 배치될 행 번호)
"""
f = ctk.CTkFrame(parent, fg_color="transparent")
f.grid(row=row, column=0, sticky="ew", padx=30, pady=(16, 4))
f.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(f, text=text, font=F_SECTION, text_color=C_MUTED).grid(row=0, column=0)
# 텍스트 오른쪽의 수평선 (1px 높이 프레임)
ctk.CTkFrame(f, fg_color=C_BORDER, height=1).grid(row=0, column=1, sticky="ew", padx=(10, 0))
return row + 1
def _card(self, parent, row):
"""
카드 컨테이너 생성 (둥근 모서리, 테두리, 내부 패딩).
Returns:
inner (CTkFrame) - 실제 위젯을 추가할 내부 프레임
"""
card = ctk.CTkFrame(parent, fg_color=C_SURFACE, corner_radius=12,
border_width=1, border_color=C_BORDER)
card.grid(row=row, column=0, sticky="ew", padx=30, pady=(0, 4))
card.grid_columnconfigure(0, weight=1)
# 카드 안쪽 패딩용 inner 프레임 (padx=16, pady=14)
inner = ctk.CTkFrame(card, fg_color="transparent")
inner.grid(padx=16, pady=14, sticky="ew")
inner.grid_columnconfigure(0, weight=1)
return inner
# =========================================================================
# 드래그앤드롭 이벤트 처리
# =========================================================================
def _on_drop(self, event):
"""
파일을 드롭했을 때 호출되는 이벤트 핸들러.
tkinterdnd2는 파일 경로를 다음 형식으로 전달:
- 일반 경로: C:/Users/user/video.mp4
- 공백 포함: {C:/Users/My Videos/video.mp4}
- 다중 파일: {C:/file1.mp4} {C:/file2.mp4}
"""
path = event.data.strip()
# 중괄호로 감싸진 경우 (공백 포함 경로) 제거
if path.startswith("{") and path.endswith("}"):
path = path[1:-1]
# 여러 파일이 드롭된 경우 첫 번째만 사용
if "} {" in path:
path = path.split("} {")[0]
self._set_video(path)
# =========================================================================
# 파일 / 폴더 선택
# =========================================================================
def _browse_video(self):
"""파일 선택 대화상자를 열어 영상 파일을 선택."""
p = filedialog.askopenfilename(
title="영상 파일 선택",
filetypes=[
("영상 파일", "*.mp4 *.mov *.avi *.mkv *.webm *.m4v *.ts *.flv"),
("모든 파일", "*.*")
])
if p:
self._set_video(p)
def _set_video(self, path):
"""
영상 파일 경로를 앱 상태에 반영하고 UI를 업데이트.
- 파일명만 추출해서 file_label에 표시
- 저장 경로를 영상이 있는 폴더로 자동 설정
- 로그에 선택 이벤트 기록
"""
if not path or not Path(path).exists():
return # 빈 경로나 존재하지 않는 파일 무시
self._video_path = path
self.file_label.configure(text=Path(path).name, text_color=C_ACCENT2)
# 저장 경로 자동 설정: 영상 파일과 같은 폴더
out = str(Path(path).parent)
self._output_dir = out
self.out_label.configure(text=out, text_color=C_TEXT)
self._log(f"영상 선택: {Path(path).name}")
def _browse_output(self):
"""폴더 선택 대화상자를 열어 SRT 저장 경로를 변경."""
d = filedialog.askdirectory(title="저장 폴더 선택")
if d:
self._output_dir = d
self.out_label.configure(text=d, text_color=C_TEXT)
def _open_output_dir(self):
"""변환 완료 후 저장 폴더를 Windows 파일 탐색기로 열기."""
if self._output_dir and Path(self._output_dir).exists():
os.startfile(self._output_dir) # Windows 전용: 기본 앱으로 경로 열기
# =========================================================================
# 의존성 확인 (FFmpeg / GPU)
# =========================================================================
def _check_deps(self):
"""
앱 시작 시 FFmpeg와 GPU를 확인하고 배너 상태를 업데이트.
FFmpeg 없음 → 빨간 배너 (변환 시도 시 에러 팝업)
GPU 없음 → 노란 배너 + CUDA 설치 안내
GPU 있음 → 초록 배너 + GPU 이름 표시
"""
# FFmpeg 상태 확인
self.ffmpeg_path = find_ffmpeg()
if self.ffmpeg_path:
self.ffmpeg_banner.configure(fg_color="#0d1a0d", border_color="#1a4a1a")
self.ffmpeg_lbl.configure(text=f"FFmpeg: {self.ffmpeg_path}", text_color=C_SUCCESS)
else:
self.ffmpeg_banner.configure(fg_color="#1a0d0d", border_color="#4a1a1a")
self.ffmpeg_lbl.configure(
text="FFmpeg 없음 - PowerShell: winget install FFmpeg 후 재시작",
text_color=C_ERROR)
# GPU(CUDA) 상태 확인
self.whisper_device, gpu_desc = detect_device()
if self.whisper_device == "cuda":
self.gpu_banner.configure(fg_color="#0d1a08", border_color="#1a5a0d")
self.gpu_lbl.configure(text=f"GPU 가속 활성화: {gpu_desc}", text_color=C_GPU)
else:
# CPU 모드 - CUDA 설치 방법 안내
self.gpu_banner.configure(fg_color="#1a1200", border_color="#5a4400")
self.gpu_lbl.configure(
text=f"CPU 모드: {gpu_desc}\n"
"GPU 사용: pip install torch --index-url https://download.pytorch.org/whl/cu121",
text_color="#ffcc44")
# =========================================================================
# 로그 출력 (스레드 안전)
# =========================================================================
def _log(self, msg):
"""
로그 텍스트박스에 타임스탬프와 함께 메시지 추가.
백그라운드 스레드에서 호출 가능: after(0, ...)로 메인 스레드에 위임.
log_box는 읽기 전용이므로 쓰기 전에 "normal", 후에 "disabled"로 전환.
"""
def _do():
self.log_box.configure(state="normal")
self.log_box.insert("end", f"[{time.strftime('%H:%M:%S')}] {msg}\n")
self.log_box.see("end") # 최신 로그가 보이도록 스크롤
self.log_box.configure(state="disabled")
self.after(0, _do)
# =========================================================================
# 변환 시작 (메인 스레드)
# =========================================================================
def _start(self):
"""
"자막 추출 시작" 버튼 클릭 핸들러.
사전 검증 후 백그라운드 스레드(_run_thread)를 시작.
UI 값을 여기서 읽어 스레드에 인자로 전달
(스레드에서 tk 객체 직접 접근 금지).
"""
# 입력 검증
if not self._video_path:
messagebox.showwarning("파일 없음", "영상 파일을 먼저 선택하세요."); return
if not Path(self._video_path).exists():
messagebox.showerror("오류", f"파일을 찾을 수 없습니다:\n{self._video_path}"); return
if not self.ffmpeg_path:
messagebox.showerror("FFmpeg 없음",
"FFmpeg가 설치되어 있지 않습니다.\n"
"PowerShell: winget install FFmpeg\n설치 후 앱 재시작"); return
if self.is_running:
return # 이미 실행 중이면 중복 실행 방지
# 실행 상태 진입
self.is_running = True
# 프로그레스바 초기화
self._progress_current = 0.0
self._progress_target = 0.0
self.progress.set(0)
self.pct_label.configure(text="0%")
# 버튼 비활성화 (처리 중 중복 클릭 방지)
self.run_btn.configure(state="disabled", text="처리 중...", fg_color=C_MUTED)
self.open_btn.configure(state="disabled")
# 로그 초기화
self.log_box.configure(state="normal")
self.log_box.delete("1.0", "end")
self.log_box.configure(state="disabled")
# ★ 중요: StringVar/tk 객체 값을 메인 스레드에서 미리 복사
# 백그라운드 스레드에서 StringVar.get() 호출 시 RuntimeError 발생
args = (
self._video_path, # 영상 파일 경로
self._output_dir, # SRT 저장 폴더
self.language.get(), # 선택된 언어 코드 ("ko", "en", ...)
self.model_var.get(), # 선택된 모델 ("tiny", "medium", ...)
self.ffmpeg_path, # FFmpeg 실행 파일 경로
self.whisper_device, # "cuda" or "cpu"
)
# 백그라운드 스레드 시작 (daemon=True: 앱 종료 시 스레드도 자동 종료)
threading.Thread(target=self._run_thread, args=args, daemon=True).start()
# =========================================================================
# 변환 워크플로우 (백그라운드 스레드)
# =========================================================================
def _run_thread(self, video_path, out_dir_str, lang, model_name, ffmpeg_exe, device):
"""
실제 변환 작업을 백그라운드 스레드에서 수행.
단계:
1. FFmpeg로 영상 → WAV 오디오 추출
2. Whisper 모델 로드 (CUDA/CPU)
3. Whisper transcribe() 음성 인식
4. SRT 파일 저장
UI 업데이트는 반드시 after(0, ...) 또는 _set_progress(), _log()를 통해 메인 스레드에 위임.
"""
import tempfile
out_dir = Path(out_dir_str)
stem = Path(video_path).stem # 확장자 제외 파일명 (SRT 파일명에 사용)
try:
# ── 1단계: 오디오 추출 ────────────────────────────────────────────
self._set_progress(8, "오디오 추출 중...")
self._log("FFmpeg로 오디오 추출 중...")
# TemporaryDirectory: 작업 완료 후 임시 WAV 파일 자동 삭제
with tempfile.TemporaryDirectory() as tmp:
audio_path = Path(tmp) / "audio.wav"
# FFmpeg 옵션:
# -y : 기존 파일 덮어쓰기 허용
# -i : 입력 파일
# -ar 16000 : 샘플레이트 16kHz (Whisper 요구사항)
# -ac 1 : 모노 채널 (Whisper 요구사항)
r = subprocess.run(
[ffmpeg_exe, "-y", "-i", video_path, "-ar", "16000", "-ac", "1", str(audio_path)],
capture_output=True) # stdout/stderr 캡처 (터미널 출력 방지)
if r.returncode != 0:
# FFmpeg 실패 시 stderr 마지막 300자를 에러 메시지로 전달
raise RuntimeError("FFmpeg 오류:\n" + r.stderr.decode("utf-8", errors="replace")[-300:])
self._log(f"오디오 추출 완료 ({audio_path.stat().st_size // 1024} KB)")
# ── 2단계: Whisper 모델 로드 ──────────────────────────────────
self._set_progress(22, f"Whisper [{model_name}] 로딩 중...")
import torch
import whisper
# 스레드에서도 CUDA 가용성 재확인 (시작 시점과 달라질 수 있음)
actual_device = device
if device == "cuda" and not torch.cuda.is_available():
actual_device = "cpu"
self._log("CUDA 재확인 실패 - CPU로 대체")
device_label = (
f"GPU ({torch.cuda.get_device_name(0)})"
if actual_device == "cuda" else "CPU"
)
self._log(f"모델: {model_name} / 장치: {device_label}")
# 모델 로드: 첫 실행 시 인터넷에서 다운로드 (~수백MB ~ 수GB)
# device 인자로 GPU/CPU 지정
wmodel = whisper.load_model(model_name, device=actual_device)
self._log("모델 로딩 완료")
# ── 3단계: 음성 인식 ──────────────────────────────────────────
self._set_progress(40, f"음성 인식 중... [{device_label}]")
self._log("음성 인식 시작 (영상 길이에 따라 수 분 소요)...")
# ---- 프로그레스 틱 스레드 ----
# Whisper는 진행 콜백을 제공하지 않으므로
# 별도 스레드에서 4초마다 3%씩 올려 "진행 중" 느낌을 줌 (40 → 84%)
progress_ticker = [40] # 리스트로 감싸 클로저에서 수정 가능하게
stop_ticker = [False] # 스레드 종료 시그널
def _tick():
while not stop_ticker[0] and progress_ticker[0] < 84:
time.sleep(4)
if stop_ticker[0]:
break
progress_ticker[0] = min(progress_ticker[0] + 3, 84)
self._set_progress(progress_ticker[0], f"음성 인식 중... [{device_label}]")
ticker_thread = threading.Thread(target=_tick, daemon=True)
ticker_thread.start()
# ---- 틱 스레드 끝 ----
# transcribe 옵션 구성
opts = {
"fp16": actual_device == "cuda" # GPU면 FP16(반정밀도) 사용 (속도↑)
# CPU면 False (경고 메시지 억제)
}
if lang != "auto":
opts["language"] = lang # 언어 지정 시 자동 감지 건너뜀 (정확도↑, 속도↑)
# ★ 핵심: Whisper 음성 인식 실행 (블로킹, 수 초 ~ 수 분)
result = wmodel.transcribe(str(audio_path), **opts)
# 인식 완료 → 틱 스레드 종료
stop_ticker[0] = True
detected = result.get("language", "알 수 없음")
self._log(f"인식 완료 / 감지 언어: {detected}")
# ── 4단계: SRT 파일 저장 ──────────────────────────────────────
self._set_progress(92, "SRT 파일 저장 중...")
srt_content = to_srt(result["segments"]) # Whisper 세그먼트 → SRT 문자열
out_dir.mkdir(parents=True, exist_ok=True) # 저장 폴더 없으면 생성
# 파일명: 영상 파일명과 동일하게 (확장자만 .srt)
srt_path = out_dir / f"{stem}.srt"
# encoding="utf-8-sig": UTF-8 BOM 포함 → Windows 메모장/Excel 호환
srt_path.write_text(srt_content, encoding="utf-8-sig")
self._log(f"저장 완료: {srt_path}")
self._set_progress(100, "완료!")
# ── 완료: 메인 스레드에서 UI 복원 ────────────────────────────
def _done():
self.run_btn.configure(state="normal", text="자막 추출 시작", fg_color=C_ACCENT)
self.open_btn.configure(state="normal") # 폴더 열기 버튼 활성화
self.status_label.configure(text_color=C_SUCCESS)
self.is_running = False
messagebox.showinfo("완료", f"자막 추출 완료!\n\n파일: {srt_path.name}\n위치: {out_dir}")
self.after(0, _done)
except Exception as e:
# ── 오류 처리: 로그 출력 + 에러 팝업 ────────────────────────────
msg = str(e)
self._log(f"오류: {msg}")
def _err():
self.run_btn.configure(state="normal", text="자막 추출 시작", fg_color=C_ACCENT)
self.status_label.configure(text="오류 발생", text_color=C_ERROR)
self.is_running = False
messagebox.showerror("오류", f"처리 중 오류:\n\n{msg}")
self.after(0, _err)
# =============================================================================
# 진입점
# =============================================================================
if __name__ == "__main__":
app = AutoSubApp()
app.mainloop() # tkinter 이벤트 루프 시작 (창 닫을 때까지 블로킹)
python autosub.py

GPU 인식되지 않는 경우

GPU 인식된 경우

정상 실행


srt 파일

| [Deep Agents] 4. backend - Sandbox (0) | 2026.04.14 |
|---|---|
| AI 모델을 이용한 자동차 번호 인식 프로그램 (0) | 2026.04.12 |
| [Deep Agents] 3. SubAgent (서브에이전트) (0) | 2026.04.11 |
| [Deep Agents] 2. Middleware (미들웨어) (0) | 2026.04.08 |
| [Deep Agents] 1. Create Deep Agent (0) | 2026.04.07 |