KeiStory

반응형

Whisper 이용한 자동 자막 추출 프로그램

 

영상작업을 할 때 필요한 게 영상에 자막을 넣는 일입니다.

VREW, 캡컷 등 여러 유료툴이 있는데 Whisper 를 이용하면 무료로 처리가 가능할 것 같아서 처리해 봤는데

생각보다 자막추출이 잘돼서 그 방법을 알아봅니다.

순서는 아래와 같습니다.

  • 영상 넣기
  • FFmpeg로 오디오 추출
  • Whisper로 음성 인식
  • SRT 자막 자동 생성

 

Whisper란 무엇인가?

Whisper는 OpenAI에서 만든 음성 인식 AI 모델(STT)입니다.

특징

  • 다양한 언어 지원 (한국어 포함)
  • 높은 정확도
  • 자동언어 감지 가능
  • 딥러닝 기반 Transformer 모델

쉽게 말하면 음성을 텍스트로 바꿔주는 AI 엔진입니다.

 

FFMPEG설치

영상에서 소리만 분류할 때 사용되는 패키지로 아래 명령으로 컴퓨터에 설치합니다.

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 앱에서 파일 드래그 앤 드롭 기능을 넣을 때 쓰는 패키지

 

pytorch 설치

아래 코드 실행해

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 인식 확인

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 파일

 

728x90
반응형

공유하기

facebook twitter kakaoTalk kakaostory naver band