FFmpeg 8.0에 포함된 whisper.cpp로 동영상 스크립트 생성해보기

FFmpeg 8.0에 포함된 whisper.cpp로 동영상 스크립트 생성해봅니다. 사람들이 많이 진행할 것으로 예상되는 윈도우에서 진행했습니다.

2025. 8. 27  최초작성

1. 다음 링크에서  FFmeag 8.0 바이너리를 다운로드합니다. 빨간색 사각형으로 표시한 것을 다운로드하면 됩니다.

https://www.gyan.dev/ffmpeg/builds/ 

2. 7z 압축이기 때문에 윈도우 자체로는 압축해제가 안됩니다. 전 반디집을 사용했습니다. 

압축 해제후, 편의상 C:\ffmpeg에 복사해두고 환경변수 PATH에 추가해야 합니다. 

윈도우 키 + R을 누르고 SystemPropertiesAdvanced 입력하여 실행 한후, 환경 변수 버튼을 클릭합니다. 

시스템 변수 항목에서 Path를 찾아 선택 후, 편집 버튼을 클릭합니다.

새로 만들기 버튼을 클릭 후, C:\ffmpeg\bin를 입력 후, 확인 버튼을 클릭합니다.

3. 시작키 누르고 cmd 입력하여 보이는  명령 프롬프트 선택하여 실행합니다. ffmpeg 명령을 실행시 아래 스크린샷처럼 보여야합니다. 

FFmepg에 whisper.cpp가 포함되었는지 확인하기 위해 다음 명령을 실행해보면

ffmpeg -filters | findstr whisper

맨 아랫줄에 다음 메시지가 출력됩니다.

.. whisper           A->A       Transcribe audio using whisper.cpp.

4. 이제 간단한 파이썬 코드를 사용하여 동영상의 스크립트를 생성해봅니다. 

파이썬 개발 환경은 다음 포스팅 내용을 참고하세요.

Visual Studio Code와 Miniconda를 사용한 Python 개발 환경 만들기( Windows, Ubuntu, WSL2)

https://webnautes.kr/visual-studio-codewa-minicondareul-sayonghan-python-gaebal-hwangyeong-mandeulgi-windows-ubuntu-wsl2/ 

다음 패키지를 설치해줘야 합니다.

pip install pyqt5 chardet

설치된 코덱이 없다면 다음 코덱이 필요할 수 있습니다. 유튜브 영상을 다운로드한 mp4 파일을 재생시 필요했습니다. 

https://codecguide.com/download_k-lite_codec_pack_basic.htm

이제 코드를 실행하여 스크립트를 생성해봅니다.

생성한 프로그램에서도 영상에 대해 바로 확인할 수 있습니다.

테스트를 위해 다음 유튜브 영상에 대한 스크립트를 생성하여 업로드해봤습니다. 성능이 어느 정도인지 궁금하신 분은 영상을 참고하세요. 

https://youtu.be/ggBNF__EV2g?si=GhRKIhH7vl1qHeKK 

전체 코드입니다.

import sys
import os
import subprocess
import json
import tempfile
import re
import chardet  # 추가: 인코딩 감지를 위한 라이브러리
from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
                            QWidget, QPushButton, QLabel, QFileDialog, QTextEdit,
                            QProgressBar, QSlider, QMessageBox)
from PyQt5.QtCore import QThread, pyqtSignal, QTimer, Qt
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
from PyQt5.QtMultimediaWidgets import QVideoWidget
from PyQt5.QtCore import QUrl

class WhisperTranscriptionThread(QThread):
    progress_updated = pyqtSignal(int)
    transcription_ready = pyqtSignal(list)
    error_signal = pyqtSignal(str)
   
    def __init__(self, video_path):
        super().__init__()
        self.video_path = video_path
       
    def run(self):
        try:
            # 임시 자막 파일 생성
            temp_srt = tempfile.mktemp(suffix='.srt')
           
            self.progress_updated.emit(10)
           
            # FFmpeg의 Whisper 필터를 사용하여 직접 자막 생성
            cmd = [
                'ffmpeg', '-i', self.video_path,
                '-af', 'aresample=16000'# 16kHz로 리샘플링
                '-f', 'wav', '-'# WAV를 stdout으로 출력
                '|', 'ffmpeg',
                '-f', 'wav', '-i', '-'# stdin에서 WAV 입력
                '-af', 'whisper=model=base:language=ko'# Whisper 필터 적용
                '-f', 'srt', temp_srt  # SRT 형식으로 출력
            ]
           
            # 더 간단한 방법: 직접 한 번에 처리
            cmd = [
                'ffmpeg', '-i', self.video_path,
                '-af', 'whisper=model=base:language=ko',
                '-f', 'srt', '-y', temp_srt
            ]
           
            self.progress_updated.emit(30)
           
            # FFmpeg 실행 - UTF-8 인코딩 명시적 지정
            process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE,
                                    universal_newlines=True,
                                    encoding='utf-8'# 추가: UTF-8 인코딩 명시
                                    errors='replace'# 추가: 오류 문자 처리
           
            stderr_output = ""
            while True:
                output = process.stderr.readline()
                if output == '' and process.poll() is not None:
                    break
                if output:
                    stderr_output += output
                    # 진행률 추정 (시간 정보를 기반으로)
                    if 'time=' in output:
                        time_match = re.search(r'time=(\d+):(\d+):(\d+)', output)
                        if time_match:
                            progress = min(90, 30 + (60 * int(time_match.group(3)) / 100))
                            self.progress_updated.emit(int(progress))
                       
            process.wait()
           
            if process.returncode == 0:
                # SRT 파일 파싱
                subtitles = self.parse_srt_file(temp_srt)
                self.progress_updated.emit(100)
                self.transcription_ready.emit(subtitles)
               
                # 임시 파일 삭제
                if os.path.exists(temp_srt):
                    os.unlink(temp_srt)
            else:
                error_msg = f"FFmpeg Whisper 처리 실패\n\n상세 오류:\n{stderr_output}"
                self.error_signal.emit(error_msg)
               
        except Exception as e:
            self.error_signal.emit(f"음성 인식 오류: {str(e)}")
           
    def parse_srt_file(self, srt_path):
        """SRT 파일을 파싱하여 자막 리스트로 변환 - 인코딩 문제 해결"""
        subtitles = []
       
        try:
            # 파일이 존재하는지 확인
            if not os.path.exists(srt_path):
                print(f"SRT 파일이 존재하지 않습니다: {srt_path}")
                return subtitles
           
            # 인코딩 감지 시도
            encoding = 'utf-8'  # 기본값
            try:
                with open(srt_path, 'rb') as f:
                    raw_data = f.read()
                    if raw_data:
                        detected = chardet.detect(raw_data)
                        if detected['encoding']:
                            encoding = detected['encoding']
                            print(f"감지된 인코딩: {encoding}")
            except Exception as e:
                print(f"인코딩 감지 실패, UTF-8 사용: {e}")
           
            # 여러 인코딩으로 시도
            encodings_to_try = [encoding, 'utf-8', 'cp949', 'euc-kr', 'latin1']
            content = None
           
            for enc in encodings_to_try:
                try:
                    with open(srt_path, 'r', encoding=enc, errors='replace') as f:
                        content = f.read()
                        print(f"파일 읽기 성공 - 인코딩: {enc}")
                        break
                except Exception as e:
                    print(f"인코딩 {enc} 실패: {e}")
                    continue
           
            if content is None:
                print("모든 인코딩 시도 실패")
                return subtitles
               
            # SRT 형식 파싱
            blocks = content.strip().split('\n\n')
           
            for block in blocks:
                lines = block.strip().split('\n')
                if len(lines) >= 3:
                    # 시간 정보 파싱
                    time_line = lines[1]
                    time_match = re.match(r'(\d+):(\d+):(\d+),(\d+) --> (\d+):(\d+):(\d+),(\d+)', time_line)
                   
                    if time_match:
                        start_time = (int(time_match.group(1)) * 3600 +
                                    int(time_match.group(2)) * 60 +
                                    int(time_match.group(3)) +
                                    int(time_match.group(4)) / 1000)
                       
                        end_time = (int(time_match.group(5)) * 3600 +
                                  int(time_match.group(6)) * 60 +
                                  int(time_match.group(7)) +
                                  int(time_match.group(8)) / 1000)
                       
                        # 텍스트 (여러 줄일 수 있음)
                        text = '\n'.join(lines[2:])
                       
                        subtitles.append({
                            "start": start_time,
                            "end": end_time,
                            "text": text
                        })
                       
        except Exception as e:
            print(f"SRT 파싱 오류: {e}")
           
        return subtitles

# VideoSubtitlePlayer 클래스는 그대로 유지...
class VideoSubtitlePlayer(QMainWindow):
    def __init__(self):
        super().__init__()
        self.subtitles = []
        self.current_subtitle_index = 0
        self.video_path = ""
       
        self.initUI()
        self.setupMediaPlayer()
        self.setupTimer()
       
    def initUI(self):
        self.setWindowTitle("FFmpeg Whisper 영상 자막 생성기")
        self.setGeometry(100, 100, 1000, 700)
       
        central_widget = QWidget()
        self.setCentralWidget(central_widget)
       
        layout = QVBoxLayout(central_widget)
       
        # 파일 선택 버튼
        file_layout = QHBoxLayout()
        self.file_button = QPushButton("영상 파일 선택")
        self.file_button.clicked.connect(self.select_video_file)
        self.file_label = QLabel("파일이 선택되지 않았습니다")
       
        file_layout.addWidget(self.file_button)
        file_layout.addWidget(self.file_label)
        layout.addLayout(file_layout)
       
        # 자막 생성 버튼과 옵션
        subtitle_layout = QHBoxLayout()
        self.generate_button = QPushButton("자막 생성 (FFmpeg Whisper)")
        self.generate_button.clicked.connect(self.generate_subtitles)
        self.generate_button.setEnabled(False)
       
        subtitle_layout.addWidget(self.generate_button)
        layout.addLayout(subtitle_layout)
       
        # 진행률 표시
        self.progress_bar = QProgressBar()
        self.progress_bar.setVisible(False)
        self.progress_label = QLabel("")
        self.progress_label.setVisible(False)
        layout.addWidget(self.progress_bar)
        layout.addWidget(self.progress_label)
       
        # 비디오 위젯
        self.video_widget = QVideoWidget()
        layout.addWidget(self.video_widget)
       
        # 현재 자막 표시
        self.subtitle_label = QLabel("")
        self.subtitle_label.setAlignment(Qt.AlignCenter)
        self.subtitle_label.setStyleSheet("""
            QLabel {
                background-color: rgba(0, 0, 0, 180);
                color: white;
                font-size: 18px;
                font-weight: bold;
                padding: 15px;
                border-radius: 8px;
                margin: 5px;
            }
        """)
        self.subtitle_label.setMaximumHeight(100)
        self.subtitle_label.setWordWrap(True)
        layout.addWidget(self.subtitle_label)
       
        # 미디어 컨트롤
        control_layout = QHBoxLayout()
       
        self.play_button = QPushButton("재생")
        self.play_button.clicked.connect(self.toggle_play)
        self.play_button.setEnabled(False)
       
        self.position_slider = QSlider(Qt.Horizontal)
        self.position_slider.sliderMoved.connect(self.set_position)
        self.position_slider.setEnabled(False)
       
        self.time_label = QLabel("00:00 / 00:00")
       
        control_layout.addWidget(self.play_button)
        control_layout.addWidget(self.position_slider)
        control_layout.addWidget(self.time_label)
       
        layout.addLayout(control_layout)
       
        # 자막 텍스트 에디터
        self.subtitle_text = QTextEdit()
        self.subtitle_text.setMaximumHeight(150)
        self.subtitle_text.setPlaceholderText("FFmpeg Whisper로 생성된 자막이 여기에 표시됩니다...")
        layout.addWidget(self.subtitle_text)
       
        # 저장 버튼
        self.save_button = QPushButton("자막 파일 저장 (SRT)")
        self.save_button.clicked.connect(self.save_subtitles)
        self.save_button.setEnabled(False)
        layout.addWidget(self.save_button)
       
    def setupMediaPlayer(self):
        # Windows에서 미디어 백엔드 설정
        import os
        os.environ['QT_MULTIMEDIA_PREFERRED_PLUGINS'] = 'windowsmediafoundation'
       
        self.media_player = QMediaPlayer(None, QMediaPlayer.VideoSurface)
        self.media_player.setVideoOutput(self.video_widget)
       
        # 에러 처리 추가
        self.media_player.error.connect(self.handle_media_error)
        self.media_player.mediaStatusChanged.connect(self.handle_media_status)
       
        self.media_player.stateChanged.connect(self.media_state_changed)
        self.media_player.positionChanged.connect(self.position_changed)
        self.media_player.durationChanged.connect(self.duration_changed)


    def handle_media_status(self, status):
        """미디어 상태 처리"""
        status_messages = {
            QMediaPlayer.NoMedia: "미디어 없음",
            QMediaPlayer.LoadingMedia: "미디어 로딩 중",
            QMediaPlayer.LoadedMedia: "미디어 로드 완료",
            QMediaPlayer.StalledMedia: "미디어 정지됨",
            QMediaPlayer.BufferingMedia: "버퍼링 중",
            QMediaPlayer.BufferedMedia: "버퍼링 완료",
            QMediaPlayer.EndOfMedia: "미디어 끝",
            QMediaPlayer.InvalidMedia: "잘못된 미디어"
        }
       
        print(f"미디어 상태: {status_messages.get(status, str(status))}")
       
        if status == QMediaPlayer.InvalidMedia:
            QMessageBox.warning(self, "미디어 오류",
                            "선택된 파일을 재생할 수 없습니다.\n다른 형식의 파일을 시도해보세요.")

    def handle_media_error(self, error):
        """미디어 에러 처리"""
        error_messages = {
            QMediaPlayer.NoError: "에러 없음",
            QMediaPlayer.ResourceError: "리소스 에러 - 파일을 찾을 수 없거나 접근할 수 없습니다",
            QMediaPlayer.FormatError: "포맷 에러 - 지원되지 않는 형식입니다",
            QMediaPlayer.NetworkError: "네트워크 에러",
            QMediaPlayer.AccessDeniedError: "접근 거부 에러",
            QMediaPlayer.ServiceMissingError: "서비스 누락 에러 - 코덱이 없을 수 있습니다"
        }
       
        error_msg = error_messages.get(error, f"알 수 없는 에러: {error}")
        print(f"미디어 에러: {error_msg}")
        QMessageBox.critical(self, "미디어 재생 오류",
                            f"비디오를 재생할 수 없습니다.\n\n{error_msg}")
       
    def setupTimer(self):
        self.timer = QTimer()
        self.timer.timeout.connect(self.update_subtitle)
        self.timer.start(100# 100ms마다 자막 업데이트
       
    def select_video_file(self):
        file_path, _ = QFileDialog.getOpenFileName(
            self, "영상 파일 선택", "",
            "Video Files (*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm *.m4v)"
        )
       
        if file_path:
            self.video_path = file_path
            self.file_label.setText(os.path.basename(file_path))
            self.generate_button.setEnabled(True)
           
            # 비디오 로드
            self.media_player.setMedia(QMediaContent(QUrl.fromLocalFile(file_path)))
            self.play_button.setEnabled(True)
            self.position_slider.setEnabled(True)
           
    def generate_subtitles(self):
        if not self.video_path:
            return
           
        self.generate_button.setEnabled(False)
        self.progress_bar.setVisible(True)
        self.progress_label.setVisible(True)
        self.progress_bar.setValue(0)
        self.progress_label.setText("FFmpeg Whisper로 음성을 인식하는 중...")
       
        # FFmpeg Whisper 스레드 시작
        self.whisper_thread = WhisperTranscriptionThread(self.video_path)
        self.whisper_thread.progress_updated.connect(self.progress_bar.setValue)
        self.whisper_thread.transcription_ready.connect(self.on_transcription_ready)
        self.whisper_thread.error_signal.connect(self.show_error)
        self.whisper_thread.start()
       
    def on_transcription_ready(self, subtitles):
        self.subtitles = subtitles
        self.progress_bar.setVisible(False)
        self.progress_label.setVisible(False)
        self.generate_button.setEnabled(True)
        self.save_button.setEnabled(True)
       
        # 자막 텍스트 표시
        subtitle_text = ""
        for i, subtitle in enumerate(subtitles):
            start_time = self.format_srt_time(subtitle["start"])
            end_time = self.format_srt_time(subtitle["end"])
            subtitle_text += f"{i+1}\n{start_time} --> {end_time}\n{subtitle['text']}\n\n"
           
        self.subtitle_text.setText(subtitle_text)
       
        QMessageBox.information(self, "완료", f"FFmpeg Whisper로 자막 생성이 완료되었습니다!\n총 {len(subtitles)}개의 자막이 생성되었습니다.")
       
    def show_error(self, error_message):
        self.progress_bar.setVisible(False)
        self.progress_label.setVisible(False)
        self.generate_button.setEnabled(True)
        QMessageBox.critical(self, "오류", error_message)
       
    def toggle_play(self):
        if self.media_player.state() == QMediaPlayer.PlayingState:
            self.media_player.pause()
        else:
            self.media_player.play()
           
    def media_state_changed(self, state):
        if state == QMediaPlayer.PlayingState:
            self.play_button.setText("일시정지")
        else:
            self.play_button.setText("재생")
           
    def position_changed(self, position):
        self.position_slider.setValue(position)
       
        # 시간 표시 업데이트
        current_time = self.format_time(position / 1000)
        total_time = self.format_time(self.media_player.duration() / 1000)
        self.time_label.setText(f"{current_time} / {total_time}")
       
    def duration_changed(self, duration):
        self.position_slider.setRange(0, duration)
       
    def set_position(self, position):
        self.media_player.setPosition(position)
       
    def update_subtitle(self):
        if not self.subtitles or self.media_player.state() != QMediaPlayer.PlayingState:
            return
           
        current_time = self.media_player.position() / 1000  # 초 단위
       
        # 현재 시간에 해당하는 자막 찾기
        current_subtitle = ""
        for subtitle in self.subtitles:
            if subtitle["start"] <= current_time <= subtitle["end"]:
                current_subtitle = subtitle["text"]
                break
               
        self.subtitle_label.setText(current_subtitle)
       
    def format_time(self, seconds):
        if seconds < 0:
            seconds = 0
        hours = int(seconds // 3600)
        minutes = int((seconds % 3600) // 60)
        secs = int(seconds % 60)
       
        if hours > 0:
            return f"{hours:02d}:{minutes:02d}:{secs:02d}"
        else:
            return f"{minutes:02d}:{secs:02d}"
           
    def save_subtitles(self):
        if not self.subtitles:
            return
           
        file_path, _ = QFileDialog.getSaveFileName(
            self, "자막 파일 저장", "", "SRT Files (*.srt)"
        )
       
        if file_path:
            try:
                # UTF-8 BOM과 함께 저장하여 인코딩 문제 방지
                with open(file_path, 'w', encoding='utf-8-sig') as f:
                    for i, subtitle in enumerate(self.subtitles):
                        start_time = self.format_srt_time(subtitle["start"])
                        end_time = self.format_srt_time(subtitle["end"])
                       
                        f.write(f"{i+1}\n")
                        f.write(f"{start_time} --> {end_time}\n")
                        f.write(f"{subtitle['text']}\n\n")
                       
                QMessageBox.information(self, "저장 완료", "자막 파일이 저장되었습니다!")
            except Exception as e:
                QMessageBox.critical(self, "저장 오류", f"파일 저장 중 오류가 발생했습니다: {str(e)}")
               
    def format_srt_time(self, seconds):
        if seconds < 0:
            seconds = 0
        hours = int(seconds // 3600)
        minutes = int((seconds % 3600) // 60)
        secs = int(seconds % 60)
        milliseconds = int((seconds - int(seconds)) * 1000)
       
        return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}"

def check_ffmpeg_whisper():
    """FFmpeg에 Whisper 지원이 있는지 확인"""
    try:
        result = subprocess.run(['ffmpeg', '-filters'],
                              capture_output=True, text=True, check=True)
        return 'whisper' in result.stdout
    except:
        return False

def main():


    app = QApplication(sys.argv)
   
    # FFmpeg 설치 확인
    try:
        result = subprocess.run(['ffmpeg', '-version'],
                              capture_output=True, text=True, check=True)
       
        # FFmpeg 버전 확인
        if 'ffmpeg version' not in result.stdout:
            raise Exception("FFmpeg 버전 정보를 찾을 수 없습니다.")
           
        # Whisper 필터 지원 확인
        if not check_ffmpeg_whisper():
            QMessageBox.warning(None, "경고",
                              "FFmpeg에 Whisper 필터가 포함되어 있지 않습니다.\n"
                              "FFmpeg 8.0 이상 버전이 필요하며, Whisper 지원이 포함된 빌드여야 합니다.\n\n"
                              "프로그램을 계속 실행하지만 자막 생성이 실패할 수 있습니다.")
       
    except (subprocess.CalledProcessError, FileNotFoundError):
        QMessageBox.critical(None, "오류",
                          "FFmpeg가 설치되어 있지 않거나 PATH에 등록되어 있지 않습니다.\n"
                          "FFmpeg 8.0 이상을 설치하고 PATH에 등록해주세요.")
        return
   
    window = VideoSubtitlePlayer()
    window.show()
   
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()