맥 팁들
Convert still image + m4a audio to mp4
제갈티
2025. 5. 9. 10:49
import os
import sys
import subprocess
import shutil
import tempfile
from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QPushButton,
QVBoxLayout, QHBoxLayout, QWidget, QFileDialog,
QMessageBox, QProgressDialog, QInputDialog, QLineEdit)
from PyQt5.QtCore import Qt, QUrl, pyqtSignal, QThread
from PyQt5.QtGui import QPixmap, QDragEnterEvent, QDropEvent
# macOS에서 일반적인 ffmpeg 위치
COMMON_FFMPEG_PATHS = [
'/usr/local/bin/ffmpeg',
'/usr/bin/ffmpeg',
'/opt/homebrew/bin/ffmpeg', # Apple Silicon Mac의 Homebrew 경로
'/opt/local/bin/ffmpeg',
'/Applications/ffmpeg',
os.path.expanduser('~/homebrew/bin/ffmpeg')
]
def find_ffmpeg():
"""시스템에서 ffmpeg 경로 찾기"""
# 1. 시스템 PATH에서 찾기
try:
result = subprocess.run(['which', 'ffmpeg'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode == 0 and result.stdout.strip():
ffmpeg_path = result.stdout.strip()
print(f"which 명령으로 찾은 ffmpeg 경로: {ffmpeg_path}")
return ffmpeg_path
except Exception as e:
print(f"which 명령 실행 중 오류: {e}")
# 2. 일반적인 위치에서 찾기
for path in COMMON_FFMPEG_PATHS:
if os.path.exists(path) and os.access(path, os.X_OK):
print(f"일반적인 위치에서 찾은 ffmpeg 경로: {path}")
return path
# 3. brew에서 설치된 위치 확인
try:
result = subprocess.run(['brew', '--prefix', 'ffmpeg'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.returncode == 0 and result.stdout.strip():
ffmpeg_prefix = result.stdout.strip()
ffmpeg_path = os.path.join(ffmpeg_prefix, 'bin', 'ffmpeg')
if os.path.exists(ffmpeg_path) and os.access(ffmpeg_path, os.X_OK):
print(f"Homebrew에서 찾은 ffmpeg 경로: {ffmpeg_path}")
return ffmpeg_path
except Exception as e:
print(f"brew 명령 실행 중 오류: {e}")
return None
# 비디오 생성 워커 스레드
class VideoCreationWorker(QThread):
finished = pyqtSignal(bool, str)
progress = pyqtSignal(int)
def __init__(self, audio_file, image_file, output_file, ffmpeg_path=None):
super().__init__()
self.audio_file = audio_file
self.image_file = image_file
self.output_file = output_file
self.ffmpeg_path = ffmpeg_path
def run(self):
try:
if not self.ffmpeg_path:
# ffmpeg가 없을 경우 간단한 처리
self.simple_process()
return
# ffmpeg 명령어 생성 - 이미지 크기를 짝수로 자동 조정하는 스케일 옵션 추가
cmd = [
self.ffmpeg_path,
'-loop', '1',
'-i', self.image_file,
'-i', self.audio_file,
'-c:v', 'libx264',
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p', # 너비와 높이를 짝수로 만들고 yuv420p 형식으로 변환
'-tune', 'stillimage',
'-c:a', 'aac',
'-b:a', '192k',
'-shortest',
'-y',
self.output_file
]
print(f"실행할 명령어: {' '.join(cmd)}")
# 진행 상황 업데이트를 위해 process 실행
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True
)
# 임의의 진행률 표시
for i in range(0, 101, 5):
if process.poll() is not None: # 프로세스가 종료되었는지 확인
break
self.progress.emit(i)
self.msleep(200) # 200ms 대기
# 프로세스 완료 대기
stdout, stderr = process.communicate()
# 성공 여부 확인
if process.returncode == 0:
self.progress.emit(100)
self.finished.emit(True, "")
else:
print(f"FFmpeg 오류: {stderr}")
self.finished.emit(False, f"FFmpeg 오류: {stderr}")
except Exception as e:
print(f"비디오 생성 중 오류: {e}")
self.finished.emit(False, str(e))
def simple_process(self):
"""ffmpeg가 없을 때 간단한 파일 복사 처리"""
try:
# 단순히 오디오 파일을 복사
shutil.copy2(self.audio_file, self.output_file)
# 진행 상황 표시
for i in range(0, 101, 10):
self.progress.emit(i)
self.msleep(200) # 200ms 대기
self.progress.emit(100)
# 메시지 생성
message = (
"참고: ffmpeg 실행 경로를 찾을 수 없어 완전한 비디오를 생성할 수 없습니다.\n"
"데모 목적으로, 오디오 파일만 복사했습니다.\n"
"실제 사용을 위해서는 ffmpeg 경로를 정확히 지정해주세요."
)
self.finished.emit(True, message)
except Exception as e:
self.finished.emit(False, str(e))
# 드래그 앤 드롭 영역 위젯
class DropArea(QLabel):
dropped = pyqtSignal(list)
def __init__(self):
super().__init__()
self.setAlignment(Qt.AlignCenter)
self.setText("오디오 파일(.m4a)과 이미지 파일을 여기에 드래그 앤 드롭하세요")
self.setStyleSheet("""
QLabel {
border: 2px dashed #aaa;
border-radius: 5px;
background-color: #f8f8f8;
padding: 30px;
font-size: 14px;
}
""")
self.setMinimumHeight(200)
self.setAcceptDrops(True)
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event: QDropEvent):
file_paths = []
for url in event.mimeData().urls():
file_paths.append(url.toLocalFile())
self.dropped.emit(file_paths)
# 메인 애플리케이션 창
class AudioImageToVideoApp(QMainWindow):
def __init__(self):
super().__init__()
# ffmpeg 경로 찾기
self.ffmpeg_path = find_ffmpeg()
# 윈도우 설정
self.setWindowTitle("오디오와 이미지로 비디오 만들기")
self.setGeometry(100, 100, 800, 600)
# 변수 초기화
self.audio_file = None
self.image_file = None
# UI 설정
self.init_ui()
# ffmpeg 설치 정보 표시
self.show_ffmpeg_info()
def show_ffmpeg_info(self):
if self.ffmpeg_path:
QMessageBox.information(
self,
"ffmpeg 발견",
f"ffmpeg를 다음 경로에서 찾았습니다:\n{self.ffmpeg_path}\n\n"
"비디오 생성 기능을 모두 사용할 수 있습니다.",
QMessageBox.Ok
)
else:
reply = QMessageBox.question(
self,
"ffmpeg 경로 설정",
"ffmpeg를 찾을 수 없습니다. 비디오를 생성하려면 ffmpeg가 필요합니다.\n\n"
"ffmpeg 경로를 직접 지정하시겠습니까?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.set_ffmpeg_path()
def set_ffmpeg_path(self):
path, ok = QInputDialog.getText(
self,
"ffmpeg 경로 설정",
"ffmpeg 실행 파일의 전체 경로를 입력하세요 (예: /opt/homebrew/bin/ffmpeg):",
QLineEdit.Normal,
""
)
if ok and path:
if os.path.exists(path) and os.access(path, os.X_OK):
self.ffmpeg_path = path
self.ffmpeg_label.setText(f"ffmpeg 경로: {path}")
self.ffmpeg_label.setStyleSheet("font-size: 12px; color: #4CAF50; margin: 5px;")
QMessageBox.information(
self,
"설정 완료",
f"ffmpeg 경로가 {path}로 설정되었습니다.",
QMessageBox.Ok
)
else:
QMessageBox.warning(
self,
"잘못된 경로",
"지정한 경로에 ffmpeg 실행 파일이 없거나 실행 권한이 없습니다.",
QMessageBox.Ok
)
def init_ui(self):
# 메인 위젯 및 레이아웃
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QVBoxLayout(main_widget)
# 제목 레이블
title_label = QLabel("오디오 파일과 이미지를 드래그 앤 드롭하여 비디오 만들기")
title_label.setStyleSheet("font-size: 18px; font-weight: bold; margin: 10px;")
title_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title_label)
# 드롭 영역
self.drop_area = DropArea()
self.drop_area.dropped.connect(self.process_dropped_files)
main_layout.addWidget(self.drop_area)
# 파일 정보 레이아웃
file_info_layout = QVBoxLayout()
# 오디오 파일 정보
self.audio_label = QLabel("오디오 파일: 선택되지 않음")
self.audio_label.setStyleSheet("font-size: 12px; margin: 5px;")
file_info_layout.addWidget(self.audio_label)
# 이미지 파일 정보
self.image_label = QLabel("이미지 파일: 선택되지 않음")
self.image_label.setStyleSheet("font-size: 12px; margin: 5px;")
file_info_layout.addWidget(self.image_label)
main_layout.addLayout(file_info_layout)
# 이미지 미리보기
preview_label = QLabel("이미지 미리보기")
preview_label.setStyleSheet("font-size: 14px; font-weight: bold; margin-top: 10px;")
preview_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(preview_label)
self.image_preview = QLabel()
self.image_preview.setAlignment(Qt.AlignCenter)
self.image_preview.setMinimumHeight(200)
self.image_preview.setStyleSheet("border: 1px solid #ddd; background-color: #f8f8f8;")
main_layout.addWidget(self.image_preview)
# 버튼 레이아웃
button_layout = QHBoxLayout()
# 파일 선택 버튼
select_button = QPushButton("파일 선택하기")
select_button.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
font-size: 14px;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #45a049;
}
""")
select_button.clicked.connect(self.select_files)
button_layout.addWidget(select_button)
# 비디오 생성 버튼
create_button = QPushButton("비디오 생성하기")
create_button.setStyleSheet("""
QPushButton {
background-color: #2196F3;
color: white;
font-size: 14px;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #0b7dda;
}
""")
create_button.clicked.connect(self.create_video)
button_layout.addWidget(create_button)
# ffmpeg 경로 설정 버튼
ffmpeg_button = QPushButton("ffmpeg 경로 설정")
ffmpeg_button.setStyleSheet("""
QPushButton {
background-color: #FF9800;
color: white;
font-size: 14px;
padding: 8px 16px;
border-radius: 4px;
}
QPushButton:hover {
background-color: #F57C00;
}
""")
ffmpeg_button.clicked.connect(self.set_ffmpeg_path)
button_layout.addWidget(ffmpeg_button)
main_layout.addLayout(button_layout)
# 상태 메시지
self.status_label = QLabel("시작하려면 파일을 드래그 앤 드롭하거나 '파일 선택하기' 버튼을 클릭하세요.")
self.status_label.setStyleSheet("font-size: 12px; color: #555; margin: 10px;")
self.status_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.status_label)
# ffmpeg 상태 메시지
ffmpeg_status = "ffmpeg 경로: " + (self.ffmpeg_path if self.ffmpeg_path else "찾을 수 없음")
self.ffmpeg_label = QLabel(ffmpeg_status)
self.ffmpeg_label.setStyleSheet(f"font-size: 12px; color: {'#4CAF50' if self.ffmpeg_path else '#FF5722'}; margin: 5px;")
self.ffmpeg_label.setAlignment(Qt.AlignCenter)
main_layout.addWidget(self.ffmpeg_label)
def process_dropped_files(self, file_paths):
for file_path in file_paths:
file_ext = os.path.splitext(file_path)[1].lower()
# 오디오 파일 처리
if file_ext in ['.m4a', '.mp3', '.wav']:
self.audio_file = file_path
self.audio_label.setText(f"오디오 파일: {os.path.basename(file_path)}")
# 이미지 파일 처리
elif file_ext in ['.jpg', '.jpeg', '.png', '.bmp', '.gif']:
self.image_file = file_path
self.image_label.setText(f"이미지 파일: {os.path.basename(file_path)}")
self.show_image_preview()
# 상태 업데이트
self.update_status()
def select_files(self):
# 파일 선택 대화상자
options = QFileDialog.Options()
files, _ = QFileDialog.getOpenFileNames(
self,
"파일 선택",
"",
"오디오 파일 (*.m4a *.mp3 *.wav);;이미지 파일 (*.jpg *.jpeg *.png *.bmp *.gif);;모든 파일 (*.*)",
options=options
)
if files:
self.process_dropped_files(files)
def show_image_preview(self):
if self.image_file:
pixmap = QPixmap(self.image_file)
if not pixmap.isNull():
# 비율 유지하며 리사이즈
pixmap = pixmap.scaled(
300, 300,
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
self.image_preview.setPixmap(pixmap)
else:
self.image_preview.setText("이미지를 표시할 수 없습니다.")
def update_status(self):
if self.audio_file and self.image_file:
self.drop_area.setText("파일이 준비되었습니다. '비디오 생성하기' 버튼을 클릭하세요.")
self.status_label.setText("두 파일이 모두 선택되었습니다. 비디오를 생성할 준비가 되었습니다.")
else:
missing = []
if not self.audio_file:
missing.append("오디오 파일")
if not self.image_file:
missing.append("이미지 파일")
missing_str = ", ".join(missing)
self.drop_area.setText(f"다음 파일이 필요합니다: {missing_str}")
self.status_label.setText(f"비디오 생성을 위해 {missing_str}을(를) 선택하세요.")
def create_video(self):
# 입력 파일 확인
if not self.audio_file or not self.image_file:
QMessageBox.warning(
self,
"경고",
"오디오 파일과 이미지 파일이 모두 필요합니다.",
QMessageBox.Ok
)
return
# ffmpeg 경로가 없으면 경고 메시지 표시
if not self.ffmpeg_path:
reply = QMessageBox.question(
self,
"ffmpeg 없음",
"ffmpeg 경로를 설정하지 않아 완전한 비디오를 생성할 수 없습니다.\n"
"ffmpeg 경로를 설정하시겠습니까?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.set_ffmpeg_path()
if not self.ffmpeg_path: # 경로 설정 취소
return
# 저장 경로 선택
options = QFileDialog.Options()
output_file, _ = QFileDialog.getSaveFileName(
self,
"비디오 저장",
"",
"MP4 비디오 (*.mp4);;모든 파일 (*.*)",
options=options
)
if not output_file:
return # 사용자가 취소함
# 올바른 확장자 확인
if not output_file.lower().endswith('.mp4'):
output_file += '.mp4'
# 진행 상황 다이얼로그
progress = QProgressDialog("비디오 생성 중...", "취소", 0, 100, self)
progress.setWindowTitle("처리 중")
progress.setWindowModality(Qt.WindowModal)
progress.setMinimumDuration(0)
progress.setAutoClose(True)
progress.setValue(0)
# 워커 스레드 생성 및 시작
self.worker = VideoCreationWorker(self.audio_file, self.image_file, output_file, self.ffmpeg_path)
self.worker.progress.connect(progress.setValue)
self.worker.finished.connect(lambda success, msg: self.on_video_created(success, msg, output_file))
self.worker.start()
def on_video_created(self, success, msg, output_file):
if success:
info_msg = f"비디오가 성공적으로 생성되었습니다.\n저장 위치: {output_file}"
if msg: # 추가 메시지가 있는 경우
info_msg = f"{msg}\n\n{info_msg}"
QMessageBox.information(
self,
"완료",
info_msg,
QMessageBox.Ok
)
self.drop_area.setText("비디오 생성이 완료되었습니다. 다른 파일을 드래그 앤 드롭하세요.")
self.status_label.setText("비디오 생성 완료! 다른 파일을 선택하여 새 비디오를 만들 수 있습니다.")
else:
QMessageBox.critical(
self,
"오류",
f"비디오 생성 중 오류가 발생했습니다: {msg}",
QMessageBox.Ok
)
self.drop_area.setText("오류가 발생했습니다. 다시 시도하세요.")
self.status_label.setText(f"오류: {msg}")
# 프로그램 실행
def main():
app = QApplication(sys.argv)
window = AudioImageToVideoApp()
window.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()