1087 lines
40 KiB
Markdown
1087 lines
40 KiB
Markdown
import csv
|
|
import logging
|
|
import os
|
|
import random
|
|
import shutil
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from moviepy.editor import (
|
|
AudioFileClip,
|
|
CompositeVideoClip,
|
|
TextClip,
|
|
VideoFileClip,
|
|
concatenate_videoclips,
|
|
)
|
|
from PyQt5.QtCore import QObject, QThread, pyqtSignal
|
|
from PyQt5.QtWidgets import (
|
|
QApplication,
|
|
QCheckBox,
|
|
QFileDialog,
|
|
QGroupBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QProgressBar,
|
|
QPushButton,
|
|
QSpinBox,
|
|
QTextEdit,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
OUTPUT_DIR = BASE_DIR / "output"
|
|
IMAGES_DIR = OUTPUT_DIR / "images"
|
|
QUIZ_DIR = OUTPUT_DIR / "quiz"
|
|
FINAL_DIR = OUTPUT_DIR / "final"
|
|
WITH_AUDIO_DIR = OUTPUT_DIR / "with_audio"
|
|
WITH_HINT_DIR = OUTPUT_DIR / "with_hint"
|
|
LOG_DIR = OUTPUT_DIR / "logs"
|
|
FINAL_MERGED_DIR = OUTPUT_DIR / "merged"
|
|
WORD_HISTORY_DIR = BASE_DIR / "Word_list"
|
|
DEFAULT_WORD_LIST_FILE = str(BASE_DIR / "word_list.txt")
|
|
DEFAULT_DUMMY_WORD_FILE = str(BASE_DIR / "dumy_word.csv")
|
|
DEFAULT_FONT_PATH = str(BASE_DIR / "KCC-Ganpan.ttf")
|
|
DEFAULT_TEST_WORD_FILE = str(WORD_HISTORY_DIR / "2024-05-13-1.txt")
|
|
|
|
DEFAULT_TEST_MODE = False
|
|
DEFAULT_PROBLEM_NO = 8
|
|
DEFAULT_GRID_WIDTH = 6
|
|
DEFAULT_GRID_HEIGHT = 4
|
|
MAX_WORD_PLACEMENT_ATTEMPTS = 200
|
|
MAX_GRID_RETRY = 30
|
|
|
|
DIRECTIONS_BY_DIFFICULTY = {
|
|
1: ["horizontal", "vertical", "diagonal", "diagonal_reverse"],
|
|
2: [
|
|
"horizontal",
|
|
"vertical",
|
|
"diagonal",
|
|
"diagonal_reverse",
|
|
"horizontal_reverse",
|
|
"vertical_reverse",
|
|
],
|
|
3: [
|
|
"horizontal",
|
|
"vertical",
|
|
"diagonal",
|
|
"diagonal_reverse",
|
|
"horizontal_reverse",
|
|
"vertical_reverse",
|
|
"diagonal_up_right",
|
|
"diagonal_up_left",
|
|
],
|
|
}
|
|
|
|
logger = logging.getLogger("quiz_maker")
|
|
logger.setLevel(logging.INFO)
|
|
logger.propagate = False
|
|
|
|
|
|
class LogEmitter(QObject):
|
|
log_signal = pyqtSignal(str)
|
|
|
|
|
|
class UiLogHandler(logging.Handler):
|
|
def __init__(self, emitter):
|
|
super().__init__()
|
|
self.emitter = emitter
|
|
|
|
def emit(self, record):
|
|
msg = self.format(record)
|
|
self.emitter.log_signal.emit(msg)
|
|
|
|
|
|
def setup_logging():
|
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
|
|
|
if logger.handlers:
|
|
return
|
|
|
|
session_log = LOG_DIR / f"quiz_maker_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
|
file_handler = logging.FileHandler(session_log, encoding="utf-8")
|
|
file_handler.setFormatter(formatter)
|
|
logger.addHandler(file_handler)
|
|
|
|
stream_handler = logging.StreamHandler()
|
|
stream_handler.setFormatter(formatter)
|
|
logger.addHandler(stream_handler)
|
|
|
|
logger.info("로그 초기화 완료: %s", session_log)
|
|
|
|
|
|
setup_logging()
|
|
|
|
|
|
TestMode = DEFAULT_TEST_MODE
|
|
Prablem_No = DEFAULT_PROBLEM_NO
|
|
|
|
|
|
def ensure_directory(path: Path):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def ensure_output_dirs():
|
|
for path in [OUTPUT_DIR, IMAGES_DIR, QUIZ_DIR, FINAL_DIR, WITH_AUDIO_DIR, WITH_HINT_DIR, LOG_DIR, FINAL_MERGED_DIR]:
|
|
ensure_directory(path)
|
|
|
|
|
|
def clear_directory(path: Path):
|
|
ensure_directory(path)
|
|
removed = 0
|
|
for entry in path.iterdir():
|
|
if entry.is_file():
|
|
entry.unlink()
|
|
removed += 1
|
|
logger.info("출력 폴더 정리: %s (%s개 파일 삭제)", path, removed)
|
|
|
|
|
|
def resolve_existing_dir(*candidates: Path):
|
|
for candidate in candidates:
|
|
if candidate.exists() and candidate.is_dir():
|
|
return candidate
|
|
return candidates[0]
|
|
|
|
|
|
def resolve_existing_file(*candidates: Path):
|
|
for candidate in candidates:
|
|
if candidate.exists() and candidate.is_file():
|
|
return candidate
|
|
return candidates[0]
|
|
|
|
|
|
def get_audio_dir() -> Path:
|
|
return resolve_existing_dir(BASE_DIR / "audios", BASE_DIR / "Audios")
|
|
|
|
|
|
def get_videos_dir() -> Path:
|
|
return resolve_existing_dir(BASE_DIR / "videos", BASE_DIR / "Videos")
|
|
|
|
|
|
def get_base_video_dir() -> Path:
|
|
return resolve_existing_dir(BASE_DIR / "long" / "base", BASE_DIR / "Long" / "base", BASE_DIR / "base")
|
|
|
|
|
|
def validate_resources(params):
|
|
problems = []
|
|
checks = [
|
|
(Path(params["word_list_file"]), "단어 리스트 파일"),
|
|
(Path(DEFAULT_DUMMY_WORD_FILE), "더미 글자 파일"),
|
|
(Path(DEFAULT_FONT_PATH), "폰트 파일"),
|
|
(WORD_HISTORY_DIR, "단어 이력 폴더"),
|
|
(get_audio_dir(), "오디오 폴더"),
|
|
(get_videos_dir(), "인트로/엔딩 영상 폴더"),
|
|
(get_base_video_dir(), "배경 영상 폴더"),
|
|
]
|
|
|
|
if params["test_mode"]:
|
|
checks.append((Path(DEFAULT_TEST_WORD_FILE), "테스트 모드 단어 파일"))
|
|
|
|
for path, label in checks:
|
|
if not path.exists():
|
|
problems.append(f"- {label} 없음: {path}")
|
|
|
|
intro_file = get_videos_dir() / "인트로.mp4"
|
|
ending_file = get_videos_dir() / "엔딩.mp4"
|
|
for path, label in [(intro_file, "인트로 영상"), (ending_file, "엔딩 영상")]:
|
|
if not path.exists():
|
|
problems.append(f"- {label} 없음: {path}")
|
|
|
|
for i in range(1, params["problem_no"] + 1):
|
|
base_video = get_base_video_dir() / f"{i}.mp4"
|
|
if not base_video.exists():
|
|
problems.append(f"- 문제 {i} 배경 영상 없음: {base_video}")
|
|
|
|
return problems
|
|
|
|
|
|
class QuizMakerThread(QThread):
|
|
progress_signal = pyqtSignal(int, str)
|
|
finished_signal = pyqtSignal()
|
|
error_signal = pyqtSignal(str)
|
|
|
|
def __init__(self, params):
|
|
super().__init__()
|
|
self.params = params
|
|
|
|
def emit_progress(self, value, message):
|
|
logger.info("진행률 %s%% - %s", value, message)
|
|
self.progress_signal.emit(value, message)
|
|
|
|
def run(self):
|
|
global TestMode, Prablem_No
|
|
try:
|
|
TestMode = self.params["test_mode"]
|
|
Prablem_No = self.params["problem_no"]
|
|
width = self.params["grid_width"]
|
|
height = self.params["grid_height"]
|
|
word_list_file = self.params["word_list_file"]
|
|
|
|
logger.info(
|
|
"작업 시작 | test_mode=%s, problem_no=%s, grid=%sx%s, word_list=%s",
|
|
TestMode,
|
|
Prablem_No,
|
|
width,
|
|
height,
|
|
word_list_file,
|
|
)
|
|
|
|
self.emit_progress(3, "리소스 검증 중...")
|
|
ensure_output_dirs()
|
|
validation_errors = validate_resources(self.params)
|
|
if validation_errors:
|
|
error_message = "필수 리소스가 부족합니다.\n" + "\n".join(validation_errors)
|
|
logger.error(error_message)
|
|
self.error_signal.emit(error_message)
|
|
return
|
|
|
|
self.emit_progress(8, "출력 폴더 정리 중...")
|
|
for path in [IMAGES_DIR, QUIZ_DIR, FINAL_DIR, WITH_AUDIO_DIR, WITH_HINT_DIR]:
|
|
clear_directory(path)
|
|
|
|
self.emit_progress(12, "더미 글자 로드 중...")
|
|
dummy_chars = make_dumy_wordlist(DEFAULT_DUMMY_WORD_FILE)
|
|
logger.info("더미 글자 수: %s", len(dummy_chars))
|
|
|
|
self.emit_progress(18, "단어 리스트 로드 중...")
|
|
word_list = load_word_list(word_list_file)
|
|
logger.info("원본 단어 수: %s", len(word_list))
|
|
|
|
self.emit_progress(24, "최근 사용 단어 이력 로드 중...")
|
|
previous_data = load_previous_data(WORD_HISTORY_DIR, 15)
|
|
logger.info("최근 사용 단어 수: %s", len(previous_data))
|
|
|
|
available_words = list(set(word_list) - set(previous_data))
|
|
logger.info("사용 가능한 단어 수: %s", len(available_words))
|
|
if not available_words:
|
|
raise RuntimeError("사용 가능한 단어가 없습니다. 단어 리스트 또는 이력 제외 규칙을 확인하세요.")
|
|
|
|
self.emit_progress(30, "퀴즈 조합 생성 중...")
|
|
if not TestMode:
|
|
quiz = generate_quiz(available_words)
|
|
else:
|
|
quiz = generate_quiz_form_file(DEFAULT_TEST_WORD_FILE)
|
|
|
|
logger.info("퀴즈 세트 생성 완료: %s문제", len(quiz))
|
|
for idx, words in enumerate(quiz, start=1):
|
|
logger.info("문제 %s 단어: %s", idx, ", ".join(words))
|
|
|
|
self.emit_progress(36, "오디오 파일 검사 중...")
|
|
verify_audio_files(quiz)
|
|
|
|
history_file_path = get_next_history_file_path(WORD_HISTORY_DIR)
|
|
logger.info("이번 실행 단어 저장 파일: %s", history_file_path)
|
|
|
|
self.emit_progress(42, "초성 힌트 생성 중...")
|
|
chosung_list = build_chosung_list(quiz)
|
|
write_quiz_summary(quiz, chosung_list)
|
|
|
|
self.emit_progress(48, "문제/정답 이미지 생성 중...")
|
|
for i, words in enumerate(quiz):
|
|
problem_no = i + 1
|
|
difficulty = 1 if i < 4 else 2 if i < 6 else 3
|
|
logger.info("문제 %s 이미지 생성 시작 | difficulty=%s | words=%s", problem_no, difficulty, words)
|
|
grid, solution_list = create_grid(width, height, words, difficulty, dummy_chars, problem_no)
|
|
items_string = "(" + ", ".join(words) + ")"
|
|
solution_image = create_solution_image(problem_no, IMAGES_DIR, items_string, grid, solution_list)
|
|
solution_image.save(IMAGES_DIR / f"{problem_no}.{items_string}_solution.png")
|
|
problem_image = create_image(grid)
|
|
problem_image.save(IMAGES_DIR / f"{problem_no}.problem.png")
|
|
logger.info("문제 %s 이미지 생성 완료 | 해설 경로 수=%s", problem_no, len(solution_list))
|
|
self.emit_progress(48 + int(problem_no * 18 / max(Prablem_No, 1)), f"문제 {problem_no} 이미지 생성 완료")
|
|
|
|
self.emit_progress(68, "문제 영상 생성 중...")
|
|
Create_video()
|
|
|
|
self.emit_progress(80, "배경 합성 영상 생성 중...")
|
|
create_Final_Video()
|
|
|
|
self.emit_progress(88, "오디오 합성 중...")
|
|
Create_Videos_With_Sound(quiz)
|
|
|
|
self.emit_progress(95, "최종 영상 병합 중...")
|
|
videos_Sum()
|
|
|
|
save_data(history_file_path, quiz)
|
|
logger.info("사용 단어 이력 저장 완료: %s", history_file_path)
|
|
|
|
self.emit_progress(100, "완료")
|
|
logger.info("전체 작업 완료")
|
|
self.finished_signal.emit()
|
|
except Exception as e:
|
|
logger.exception("퀴즈 생성 중 오류 발생")
|
|
self.error_signal.emit(f"오류 발생: {str(e)}")
|
|
|
|
|
|
class QuizMakerUI(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.initUI()
|
|
|
|
def initUI(self):
|
|
self.setWindowTitle("퀴즈 메이커 V1.3 UI")
|
|
self.setGeometry(100, 100, 800, 600)
|
|
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
main_layout = QVBoxLayout(central_widget)
|
|
|
|
settings_group = QGroupBox("설정")
|
|
settings_layout = QVBoxLayout()
|
|
|
|
test_mode_layout = QHBoxLayout()
|
|
self.test_mode_checkbox = QCheckBox("테스트 모드")
|
|
self.test_mode_checkbox.setChecked(DEFAULT_TEST_MODE)
|
|
test_mode_layout.addWidget(self.test_mode_checkbox)
|
|
settings_layout.addLayout(test_mode_layout)
|
|
|
|
problem_layout = QHBoxLayout()
|
|
problem_layout.addWidget(QLabel("문제 수:"))
|
|
self.problem_spinbox = QSpinBox()
|
|
self.problem_spinbox.setRange(1, 20)
|
|
self.problem_spinbox.setValue(DEFAULT_PROBLEM_NO)
|
|
problem_layout.addWidget(self.problem_spinbox)
|
|
settings_layout.addLayout(problem_layout)
|
|
|
|
grid_layout = QHBoxLayout()
|
|
grid_layout.addWidget(QLabel("그리드 크기:"))
|
|
self.width_spinbox = QSpinBox()
|
|
self.width_spinbox.setRange(3, 10)
|
|
self.width_spinbox.setValue(DEFAULT_GRID_WIDTH)
|
|
grid_layout.addWidget(self.width_spinbox)
|
|
grid_layout.addWidget(QLabel("x"))
|
|
self.height_spinbox = QSpinBox()
|
|
self.height_spinbox.setRange(3, 10)
|
|
self.height_spinbox.setValue(DEFAULT_GRID_HEIGHT)
|
|
grid_layout.addWidget(self.height_spinbox)
|
|
settings_layout.addLayout(grid_layout)
|
|
|
|
file_layout = QHBoxLayout()
|
|
file_layout.addWidget(QLabel("단어 리스트 파일:"))
|
|
self.word_list_edit = QLineEdit(DEFAULT_WORD_LIST_FILE)
|
|
file_layout.addWidget(self.word_list_edit)
|
|
browse_button = QPushButton("찾아보기")
|
|
browse_button.clicked.connect(self.browse_word_list)
|
|
file_layout.addWidget(browse_button)
|
|
settings_layout.addLayout(file_layout)
|
|
|
|
settings_group.setLayout(settings_layout)
|
|
main_layout.addWidget(settings_group)
|
|
|
|
progress_group = QGroupBox("진행 상황")
|
|
progress_layout = QVBoxLayout()
|
|
self.progress_bar = QProgressBar()
|
|
self.progress_bar.setRange(0, 100)
|
|
self.progress_bar.setValue(0)
|
|
progress_layout.addWidget(self.progress_bar)
|
|
self.status_label = QLabel("준비됨")
|
|
progress_layout.addWidget(self.status_label)
|
|
progress_group.setLayout(progress_layout)
|
|
main_layout.addWidget(progress_group)
|
|
|
|
log_group = QGroupBox("로그")
|
|
log_layout = QVBoxLayout()
|
|
self.log_text = QTextEdit()
|
|
self.log_text.setReadOnly(True)
|
|
log_layout.addWidget(self.log_text)
|
|
log_group.setLayout(log_layout)
|
|
main_layout.addWidget(log_group)
|
|
|
|
button_layout = QHBoxLayout()
|
|
self.start_button = QPushButton("시작")
|
|
self.start_button.clicked.connect(self.start_quiz_maker)
|
|
button_layout.addWidget(self.start_button)
|
|
self.cancel_button = QPushButton("취소")
|
|
self.cancel_button.clicked.connect(self.close)
|
|
button_layout.addWidget(self.cancel_button)
|
|
main_layout.addLayout(button_layout)
|
|
|
|
self.setup_logging()
|
|
|
|
def setup_logging(self):
|
|
self.log_emitter = LogEmitter()
|
|
self.log_emitter.log_signal.connect(self.append_log)
|
|
handler = UiLogHandler(self.log_emitter)
|
|
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
|
logger.addHandler(handler)
|
|
|
|
def append_log(self, message):
|
|
self.log_text.append(message)
|
|
|
|
def browse_word_list(self):
|
|
file_path, _ = QFileDialog.getOpenFileName(self, "단어 리스트 파일 선택", str(BASE_DIR), "텍스트 파일 (*.txt)")
|
|
if file_path:
|
|
self.word_list_edit.setText(file_path)
|
|
|
|
def start_quiz_maker(self):
|
|
params = {
|
|
"test_mode": self.test_mode_checkbox.isChecked(),
|
|
"problem_no": self.problem_spinbox.value(),
|
|
"grid_width": self.width_spinbox.value(),
|
|
"grid_height": self.height_spinbox.value(),
|
|
"word_list_file": self.word_list_edit.text().strip(),
|
|
}
|
|
|
|
validation_errors = validate_resources(params)
|
|
if validation_errors:
|
|
QMessageBox.warning(self, "리소스 확인", "필수 리소스를 확인하세요.\n\n" + "\n".join(validation_errors))
|
|
return
|
|
|
|
logger.info("UI에서 작업 시작 요청")
|
|
self.thread = QuizMakerThread(params)
|
|
self.thread.progress_signal.connect(self.update_progress)
|
|
self.thread.finished_signal.connect(self.on_finished)
|
|
self.thread.error_signal.connect(self.on_error)
|
|
|
|
self.start_button.setEnabled(False)
|
|
self.progress_bar.setValue(0)
|
|
self.status_label.setText("시작 중...")
|
|
self.thread.start()
|
|
|
|
def update_progress(self, value, message):
|
|
self.progress_bar.setValue(value)
|
|
self.status_label.setText(message)
|
|
|
|
def on_finished(self):
|
|
self.start_button.setEnabled(True)
|
|
self.status_label.setText("완료")
|
|
QMessageBox.information(self, "완료", "퀴즈 생성이 완료되었습니다.")
|
|
|
|
def on_error(self, message):
|
|
self.start_button.setEnabled(True)
|
|
self.status_label.setText("오류 발생")
|
|
QMessageBox.critical(self, "오류", message)
|
|
|
|
|
|
def build_chosung_list(quiz):
|
|
chosungs = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"]
|
|
chosung_list = []
|
|
for lists in quiz:
|
|
temp = ""
|
|
for word in lists:
|
|
for char in word:
|
|
uni_base = ord(char) - 44032
|
|
chosung = (uni_base // 28) // 21
|
|
temp += chosungs[chosung]
|
|
temp += ","
|
|
chosung_list.append(temp[:-1])
|
|
return chosung_list
|
|
|
|
|
|
def write_quiz_summary(quiz, chosung_list):
|
|
output_file = IMAGES_DIR / "Quiz.txt"
|
|
with open(output_file, "w", encoding="utf-8") as f_output:
|
|
for i, lists in enumerate(quiz, 1):
|
|
words = ", ".join(lists)
|
|
f_output.write(f"문제 {i}:\t{words}\t{chosung_list[i-1]}\n")
|
|
logger.info("퀴즈 요약 저장 완료: %s", output_file)
|
|
|
|
|
|
def get_next_history_file_path(directory: Path):
|
|
ensure_directory(directory)
|
|
file_prefix = datetime.today().strftime("%Y-%m-%d")
|
|
existing_numbers = []
|
|
for file in directory.iterdir():
|
|
if file.is_file() and file.name.startswith(file_prefix):
|
|
try:
|
|
existing_numbers.append(int(file.stem[len(file_prefix) + 1 :]))
|
|
except ValueError:
|
|
continue
|
|
next_number = max(existing_numbers) + 1 if existing_numbers else 1
|
|
return directory / f"{file_prefix}-{next_number}.txt"
|
|
|
|
|
|
def verify_audio_files(quiz):
|
|
audio_dir = get_audio_dir()
|
|
missing_audio = []
|
|
for words in quiz:
|
|
for word in words:
|
|
audio_file = audio_dir / f"{word}.mp3"
|
|
if not audio_file.exists():
|
|
missing_audio.append(word)
|
|
if missing_audio:
|
|
logger.error("누락 오디오: %s", ", ".join(missing_audio))
|
|
raise FileNotFoundError(f"다음 단어의 오디오 파일이 없습니다: {', '.join(missing_audio)}")
|
|
logger.info("오디오 파일 검사 완료")
|
|
|
|
|
|
# 기존 함수 보강
|
|
|
|
def create_grid(width, height, words, difficulty, dummy_chars=None, problem_no=None):
|
|
if dummy_chars is None:
|
|
dummy_chars = make_dumy_wordlist(DEFAULT_DUMMY_WORD_FILE)
|
|
|
|
directions_master = DIRECTIONS_BY_DIFFICULTY.get(difficulty, DIRECTIONS_BY_DIFFICULTY[3])
|
|
|
|
for grid_retry in range(1, MAX_GRID_RETRY + 1):
|
|
grid = [[" " for _ in range(width)] for _ in range(height)]
|
|
solution_list = []
|
|
available_directions = directions_master.copy()
|
|
logger.info(
|
|
"문제 %s 퍼즐 배치 시도 %s/%s | width=%s height=%s difficulty=%s words=%s",
|
|
problem_no,
|
|
grid_retry,
|
|
MAX_GRID_RETRY,
|
|
width,
|
|
height,
|
|
difficulty,
|
|
words,
|
|
)
|
|
|
|
failed = False
|
|
for word in words:
|
|
placed, solution, selected_direction = place_word_in_grid(
|
|
grid=grid,
|
|
width=width,
|
|
height=height,
|
|
word=word,
|
|
candidate_directions=available_directions,
|
|
problem_no=problem_no,
|
|
)
|
|
if not placed:
|
|
logger.warning("문제 %s 단어 배치 실패 | word=%s | grid_retry=%s", problem_no, word, grid_retry)
|
|
failed = True
|
|
break
|
|
|
|
solution_list.append(solution)
|
|
if selected_direction in available_directions:
|
|
available_directions.remove(selected_direction)
|
|
logger.info("문제 %s 단어 배치 성공 | word=%s | direction=%s | coords=%s", problem_no, word, selected_direction, solution)
|
|
|
|
if failed:
|
|
continue
|
|
|
|
fill_empty_cells(grid, dummy_chars)
|
|
logger.info("문제 %s 퍼즐 배치 완료", problem_no)
|
|
return grid, solution_list
|
|
|
|
raise RuntimeError(f"문제 {problem_no} 퍼즐 생성 실패: 단어 배치를 {MAX_GRID_RETRY}회 시도했지만 완료하지 못했습니다.")
|
|
|
|
|
|
def place_word_in_grid(grid, width, height, word, candidate_directions, problem_no=None):
|
|
directions = candidate_directions or DIRECTIONS_BY_DIFFICULTY[3]
|
|
if not directions:
|
|
directions = DIRECTIONS_BY_DIFFICULTY[3]
|
|
|
|
word_length = len(word)
|
|
for attempt in range(1, MAX_WORD_PLACEMENT_ATTEMPTS + 1):
|
|
direction = random.choice(directions)
|
|
coords = get_candidate_coordinates(width, height, word_length, direction)
|
|
if coords is None:
|
|
logger.debug("문제 %s 좌표 계산 불가 | word=%s | direction=%s", problem_no, word, direction)
|
|
continue
|
|
|
|
x, y = coords
|
|
result = try_place_word(grid, word, x, y, direction)
|
|
if result is not None:
|
|
return True, result, direction
|
|
|
|
if attempt in (1, 10, 50, 100, MAX_WORD_PLACEMENT_ATTEMPTS):
|
|
logger.info(
|
|
"문제 %s 단어 재시도 | word=%s | attempt=%s/%s | direction=%s",
|
|
problem_no,
|
|
word,
|
|
attempt,
|
|
MAX_WORD_PLACEMENT_ATTEMPTS,
|
|
direction,
|
|
)
|
|
return False, [], None
|
|
|
|
|
|
def get_candidate_coordinates(width, height, word_length, direction):
|
|
if direction in ("horizontal", "horizontal_reverse"):
|
|
if width < word_length:
|
|
return None
|
|
return random.randint(0, width - word_length), random.randint(0, height - 1)
|
|
if direction in ("vertical", "vertical_reverse"):
|
|
if height < word_length:
|
|
return None
|
|
return random.randint(0, width - 1), random.randint(0, height - word_length)
|
|
if direction in ("diagonal", "diagonal_reverse", "diagonal_up_left"):
|
|
if width < word_length or height < word_length:
|
|
return None
|
|
return random.randint(0, width - word_length), random.randint(0, height - word_length)
|
|
if direction == "diagonal_up_right":
|
|
if width < word_length or height < word_length:
|
|
return None
|
|
return random.randint(0, width - word_length), random.randint(word_length - 1, height - 1)
|
|
return None
|
|
|
|
|
|
def try_place_word(grid, word, x, y, direction):
|
|
word_length = len(word)
|
|
coords = []
|
|
|
|
if direction == "horizontal":
|
|
cells = [(y, x + i, word[i]) for i in range(word_length)]
|
|
elif direction == "vertical":
|
|
cells = [(y + i, x, word[i]) for i in range(word_length)]
|
|
elif direction == "diagonal":
|
|
cells = [(y + i, x + i, word[i]) for i in range(word_length)]
|
|
elif direction == "diagonal_reverse":
|
|
cells = [(y + i, x + word_length - i - 1, word[i]) for i in range(word_length)]
|
|
elif direction == "horizontal_reverse":
|
|
cells = [(y, x + i, word[word_length - i - 1]) for i in range(word_length)]
|
|
elif direction == "vertical_reverse":
|
|
cells = [(y + i, x, word[word_length - i - 1]) for i in range(word_length)]
|
|
elif direction == "diagonal_up_right":
|
|
cells = [(y - i, x + i, word[i]) for i in range(word_length)]
|
|
elif direction == "diagonal_up_left":
|
|
cells = [(y + i, x + i, word[word_length - i - 1]) for i in range(word_length)]
|
|
else:
|
|
return None
|
|
|
|
if not all(grid[row][col] == " " for row, col, _ in cells):
|
|
return None
|
|
|
|
for row, col, char in cells:
|
|
grid[row][col] = char
|
|
|
|
if direction == "horizontal_reverse":
|
|
coords = [[y, x + word_length - i - 1] for i in range(word_length)]
|
|
elif direction == "vertical_reverse":
|
|
coords = [[y + word_length - i - 1, x] for i in range(word_length)]
|
|
elif direction == "diagonal_up_left":
|
|
coords = [[y + word_length - i - 1, x + word_length - i - 1] for i in range(word_length)]
|
|
else:
|
|
coords = [[row, col] for row, col, _ in cells]
|
|
|
|
return coords
|
|
|
|
|
|
def fill_empty_cells(grid, dummy_chars):
|
|
for i in range(len(grid)):
|
|
for j in range(len(grid[0])):
|
|
if grid[i][j] == " ":
|
|
while True:
|
|
c = random.choice(dummy_chars)
|
|
if c != " ":
|
|
grid[i][j] = c
|
|
break
|
|
|
|
|
|
def make_dumy_wordlist(file_name):
|
|
dummy_word_list = ""
|
|
with open(file_name, "r", encoding="utf-8") as file:
|
|
reader = csv.reader(file)
|
|
for row in reader:
|
|
for word in row:
|
|
dummy_word_list += word
|
|
return dummy_word_list
|
|
|
|
|
|
def display_grid(grid):
|
|
for row in grid:
|
|
print(" ".join(row))
|
|
|
|
|
|
def find_word(grid, word):
|
|
word_length = len(word)
|
|
height = len(grid)
|
|
width = len(grid[0])
|
|
for i in range(height):
|
|
for j in range(width):
|
|
if grid[i][j] == word[0]:
|
|
if j + word_length <= width and "".join(grid[i][j:j + word_length]) == word:
|
|
return (i, j), (i, j + word_length - 1), False
|
|
if j - word_length >= -1 and "".join(grid[i][j:j - word_length - 1:-1]) == word:
|
|
return (i, j), (i, j - word_length + 1), True
|
|
if i + word_length <= height and "".join(grid[k][j] for k in range(i, i + word_length)) == word:
|
|
return (i, j), (i + word_length - 1, j), False
|
|
if i - word_length >= -1 and "".join(grid[k][j] for k in range(i, i - word_length - 1, -1)) == word:
|
|
return (i, j), (i - word_length + 1, j), True
|
|
if j + word_length <= width and i + word_length <= height and "".join(grid[i + k][j + k] for k in range(word_length)) == word:
|
|
return (i, j), (i + word_length - 1, j + word_length - 1), False
|
|
if j - word_length >= -1 and i + word_length <= height and "".join(grid[i + k][j - k] for k in range(word_length)) == word:
|
|
return (i, j), (i + word_length - 1, j - word_length + 1), True
|
|
if j + word_length <= width and i - word_length >= -1 and "".join(grid[i - k][j + k] for k in range(word_length)) == word:
|
|
return (i, j), (i - word_length + 1, j + word_length - 1), True
|
|
return None
|
|
|
|
|
|
def create_image(grid, cell_size=500, border=10):
|
|
width = len(grid[0])
|
|
height = len(grid)
|
|
image_width = width * cell_size + border * 9
|
|
image_height = height * cell_size + border * 7
|
|
image = Image.new("RGBA", (image_width, image_height), (255, 255, 255, 255))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.rectangle([(border, border), (image_width - border, image_height - border)], outline="black", width=border)
|
|
|
|
for i in range(height):
|
|
y = i * cell_size + border * (i + 2)
|
|
draw.line([(border, y), (image_width - border, y)], fill="black", width=border)
|
|
|
|
for j in range(width):
|
|
x = j * cell_size + border * (j + 2)
|
|
draw.line([(x, border), (x, image_height - border)], fill="black", width=border)
|
|
|
|
font = ImageFont.truetype(DEFAULT_FONT_PATH, size=250)
|
|
|
|
for i in range(height):
|
|
for j in range(width):
|
|
char = grid[i][j]
|
|
x = j * cell_size + cell_size // 2 + border * (j + 1)
|
|
y = i * cell_size + cell_size // 2 + border * (i + 1)
|
|
draw.text((x, y), char, fill="black", font=font, anchor="mm")
|
|
|
|
return image
|
|
|
|
|
|
def draw_rounded_rectangle(draw, xy, radius, fill=None, outline=None):
|
|
x1, y1, x2, y2 = xy
|
|
draw.rectangle([x1 + radius, y1, x2 - radius, y2], fill=fill, outline=outline)
|
|
draw.rectangle([x1, y1 + radius, x2, y2 - radius], fill=fill, outline=outline)
|
|
draw.pieslice([x1, y1, x1 + radius * 2, y1 + radius * 2], start=180, end=270, fill=fill, outline=outline)
|
|
draw.pieslice([x2 - radius * 2, y1, x2, y1 + radius * 2], start=270, end=360, fill=fill, outline=outline)
|
|
draw.pieslice([x1, y2 - radius * 2, x1 + radius * 2, y2], start=90, end=180, fill=fill, outline=outline)
|
|
draw.pieslice([x2 - radius * 2, y2 - radius * 2, x2, y2], start=0, end=90, fill=fill, outline=outline)
|
|
|
|
|
|
def create_solution_image(index, folder_path, items_string, grid, solution_list, cell_size=500, border=10, Alpa=100):
|
|
width = len(grid[0])
|
|
height = len(grid)
|
|
image_width = width * cell_size + border * 9
|
|
image_height = height * cell_size + border * 7
|
|
image = Image.new("RGBA", (image_width, image_height), (255, 255, 255, 255))
|
|
draw = ImageDraw.Draw(image)
|
|
draw.rectangle([(border, border), (image_width - border, image_height - border)], outline="black", width=border)
|
|
|
|
for i in range(height):
|
|
y = i * cell_size + border * (i + 2)
|
|
draw.line([(border, y), (image_width - border, y)], fill="black", width=border)
|
|
|
|
for j in range(width):
|
|
x = j * cell_size + border * (j + 2)
|
|
draw.line([(x, border), (x, image_height - border)], fill="black", width=border)
|
|
|
|
font = ImageFont.truetype(DEFAULT_FONT_PATH, size=250)
|
|
|
|
k = 0
|
|
for solution in solution_list:
|
|
if k == 0:
|
|
fill_color = (255, 31, 31, Alpa)
|
|
elif k == 1:
|
|
fill_color = (0x5E, 0x17, 0xEB, Alpa)
|
|
elif k == 2:
|
|
fill_color = (0x7E, 0xD9, 0x57, Alpa)
|
|
else:
|
|
fill_color = (0x5C, 0xE1, 0xE6, Alpa)
|
|
|
|
k += 1
|
|
a = 1
|
|
for g in solution:
|
|
i = g[0]
|
|
j = g[1]
|
|
x = j * cell_size + border * (j + 2) + cell_size / 10
|
|
y = i * cell_size + border * (i + 2) + cell_size / 10
|
|
xy = (x, y, x + cell_size * 8 / 10, y + cell_size * 8 / 10)
|
|
draw_rounded_rectangle(draw, xy, 100, fill=fill_color)
|
|
|
|
for row in range(height):
|
|
for col in range(width):
|
|
char = grid[row][col]
|
|
tx = col * cell_size + cell_size // 2 + border * (col + 1)
|
|
ty = row * cell_size + cell_size // 2 + border * (row + 1)
|
|
draw.text((tx, ty), char, fill="black", font=font, anchor="mm")
|
|
|
|
image.save(Path(folder_path) / f"{index}.solution{k}_{a}.png")
|
|
a += 1
|
|
|
|
return image
|
|
|
|
|
|
word_list_file = DEFAULT_WORD_LIST_FILE
|
|
|
|
|
|
def select_words(word_list, length, count):
|
|
candidates = [word for word in word_list if len(word) == length]
|
|
if len(candidates) < count:
|
|
raise ValueError(f"길이 {length} 단어가 부족합니다. 필요={count}, 현재={len(candidates)}")
|
|
selected_words = random.sample(candidates, count)
|
|
logger.info("단어 선택 | len=%s count=%s selected=%s", length, count, selected_words)
|
|
return selected_words
|
|
|
|
|
|
def generate_quiz_form_file(file_name):
|
|
quiz = []
|
|
words = []
|
|
with open(file_name, "r", encoding="utf-8") as file:
|
|
lines = file.readlines()
|
|
i = 0
|
|
for word in lines:
|
|
words.append(word.strip())
|
|
i += 1
|
|
if i in (3, 6, 9, 12, 15, 18, 21, 25):
|
|
quiz.append(words)
|
|
words = []
|
|
logger.info("테스트 파일 기반 퀴즈 생성 완료: %s", file_name)
|
|
return quiz
|
|
|
|
|
|
def generate_quiz(word_list):
|
|
quiz = []
|
|
logger.info("퀴즈 조합 생성 시작 | target_problem_count=%s", Prablem_No)
|
|
for i in range(Prablem_No):
|
|
if i < 4:
|
|
words = select_words(word_list, 3, 3)
|
|
word_list = list(set(word_list) - set(words))
|
|
quiz.append(words)
|
|
elif i < 7:
|
|
words_3 = select_words(word_list, 3, 2)
|
|
words_4 = select_words(word_list, 4, 1)
|
|
word_list = list(set(word_list) - set(words_3) - set(words_4))
|
|
quiz.append(words_3 + words_4)
|
|
else:
|
|
words_3 = select_words(word_list, 3, 2)
|
|
words_4 = select_words(word_list, 4, 2)
|
|
word_list = list(set(word_list) - set(words_3) - set(words_4))
|
|
quiz.append(words_3 + words_4)
|
|
logger.info("문제 %s 조합 확정: %s", i + 1, quiz[-1])
|
|
return quiz
|
|
|
|
|
|
def load_word_list(filename):
|
|
with open(filename, "r", encoding="utf-8") as file:
|
|
words = [line.strip() for line in file if line.strip()]
|
|
logger.info("단어 파일 로드 완료: %s (%s개)", filename, len(words))
|
|
return words
|
|
|
|
|
|
def split_words(data):
|
|
words = []
|
|
for item in data:
|
|
words.extend(item[0].split(", "))
|
|
return words
|
|
|
|
|
|
def load_previous_data(folder_path, file_limit):
|
|
previous_data = []
|
|
files = []
|
|
folder_path = Path(folder_path)
|
|
if not folder_path.exists():
|
|
logger.warning("이력 폴더가 없습니다: %s", folder_path)
|
|
return previous_data
|
|
|
|
for file_name in folder_path.iterdir():
|
|
if file_name.suffix == ".txt":
|
|
files.append(file_name)
|
|
|
|
files.sort(reverse=True)
|
|
for file_name in files[:file_limit]:
|
|
try:
|
|
with open(file_name, "r", encoding="utf-8") as file:
|
|
data = [line.strip() for line in file if line.strip()]
|
|
previous_data.extend(data)
|
|
except FileNotFoundError:
|
|
logger.warning("이력 파일 없음: %s", file_name)
|
|
logger.info("최근 이력 로드 완료 | files=%s words=%s", min(len(files), file_limit), len(previous_data))
|
|
return previous_data
|
|
|
|
|
|
def save_data(filename, data):
|
|
with open(filename, "a", encoding="utf-8") as file:
|
|
for item in data:
|
|
for word in item:
|
|
file.write(word + "\n")
|
|
|
|
|
|
def Overlay_Hint(chosung_list):
|
|
text_color = "white"
|
|
stroke_color = "rgb(110, 110, 110)"
|
|
position = (543, 954)
|
|
text_start_time = 39
|
|
text_duration_per_char = 0.15
|
|
text_diapper_per_char = 0.1
|
|
font_path = str(Path(DEFAULT_FONT_PATH))
|
|
|
|
for i in range(Prablem_No):
|
|
video_path = WITH_AUDIO_DIR / f"{i + 1}.mp4"
|
|
video_clip = VideoFileClip(str(video_path))
|
|
text = "Hint: " + chosung_list[i]
|
|
text_clips = []
|
|
|
|
for j in range(1, len(text) + 1):
|
|
char_clip = (
|
|
TextClip(text[:j], fontsize=80, color=text_color, stroke_color=stroke_color, stroke_width=3, font=font_path)
|
|
.set_position(position)
|
|
.set_start(text_start_time + (j - 1) * text_duration_per_char)
|
|
.set_duration(video_clip.duration - (text_start_time - (j - 1) * text_diapper_per_char) - 5.5)
|
|
.crossfadeout(text_diapper_per_char)
|
|
)
|
|
text_clips.append(char_clip)
|
|
|
|
typing_clip = CompositeVideoClip([video_clip] + text_clips)
|
|
output_path = WITH_HINT_DIR / f"{i + 1}.mp4"
|
|
typing_clip.write_videofile(str(output_path), codec="libx264", fps=video_clip.fps)
|
|
logger.info("힌트 오버레이 완료: %s", output_path)
|
|
|
|
|
|
def Create_video():
|
|
first_image = IMAGES_DIR / "1.problem.png"
|
|
frame = cv2.imread(str(first_image))
|
|
if frame is None:
|
|
raise FileNotFoundError(f"첫 문제 이미지 로드 실패: {first_image}")
|
|
|
|
fps = 30
|
|
problem_time = 71.5
|
|
solution_view_delay = 0.3
|
|
solution_delay = 1
|
|
height, width, layers = frame.shape
|
|
|
|
ensure_directory(QUIZ_DIR)
|
|
logger.info("문제 영상 생성 시작 | total=%s | fps=%s | size=%sx%s", Prablem_No, fps, width, height)
|
|
|
|
for i in range(Prablem_No):
|
|
quiz_video_path = QUIZ_DIR / f"{i + 1}.mp4"
|
|
video = cv2.VideoWriter(str(quiz_video_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height))
|
|
problem_image = IMAGES_DIR / f"{i + 1}.problem.png"
|
|
|
|
if not problem_image.exists():
|
|
logger.warning("문제 이미지 없음: %s", problem_image)
|
|
video.release()
|
|
continue
|
|
|
|
frame = cv2.imread(str(problem_image))
|
|
logger.info("문제 %s 영상 시작 | source=%s", i + 1, problem_image)
|
|
for _ in range(int(problem_time * fps)):
|
|
video.write(frame)
|
|
|
|
max_j = 3 if i < 4 else 4
|
|
for j in range(max_j):
|
|
solution_image = IMAGES_DIR / f"{i + 1}.solution{j + 1}_1.png"
|
|
if not solution_image.exists():
|
|
logger.info("문제 %s 솔루션 %s 첫 이미지 없음", i + 1, j + 1)
|
|
continue
|
|
|
|
for k in range(4):
|
|
solution_image = IMAGES_DIR / f"{i + 1}.solution{j + 1}_{k + 1}.png"
|
|
if not solution_image.exists():
|
|
logger.info("문제 %s 솔루션 프레임 없음: %s", i + 1, solution_image)
|
|
continue
|
|
frame = cv2.imread(str(solution_image))
|
|
for _ in range(int(solution_view_delay * fps)):
|
|
video.write(frame)
|
|
|
|
for _ in range(int(solution_delay * fps)):
|
|
video.write(frame)
|
|
|
|
for _ in range(int(2 * fps)):
|
|
video.write(frame)
|
|
|
|
video.release()
|
|
logger.info("문제 %s 영상 생성 완료: %s", i + 1, quiz_video_path)
|
|
|
|
|
|
def Create_Videos_With_Sound(quiz):
|
|
word_time = 79
|
|
word_dealy = 2
|
|
audio_dir = get_audio_dir()
|
|
ensure_directory(WITH_AUDIO_DIR)
|
|
|
|
for i in range(Prablem_No):
|
|
video_path = FINAL_DIR / f"{i + 1}.mp4"
|
|
video_clip = VideoFileClip(str(video_path))
|
|
words = quiz[i]
|
|
audio_clips = [video_clip.audio]
|
|
for word in words:
|
|
audio_file = audio_dir / f"{word}.mp3"
|
|
audio_clips.append(AudioFileClip(str(audio_file)))
|
|
logger.info("문제 %s 오디오 추가 예약 | word=%s | file=%s", i + 1, word, audio_file)
|
|
|
|
if len(audio_clips) == 4:
|
|
video_clip = CompositeVideoClip([
|
|
video_clip.set_audio(audio_clips[0].set_start(0)),
|
|
video_clip.set_audio(audio_clips[1].set_start(word_time + 0 * word_dealy)),
|
|
video_clip.set_audio(audio_clips[2].set_start(word_time + 1 * word_dealy)),
|
|
video_clip.set_audio(audio_clips[3].set_start(word_time + 2 * word_dealy)),
|
|
])
|
|
else:
|
|
video_clip = CompositeVideoClip([
|
|
video_clip.set_audio(audio_clips[0].set_start(0)),
|
|
video_clip.set_audio(audio_clips[1].set_start(word_time + 0 * word_dealy)),
|
|
video_clip.set_audio(audio_clips[2].set_start(word_time + 1 * word_dealy)),
|
|
video_clip.set_audio(audio_clips[3].set_start(word_time + 2 * word_dealy)),
|
|
video_clip.set_audio(audio_clips[4].set_start(word_time + 3 * word_dealy)),
|
|
])
|
|
|
|
output_path = WITH_AUDIO_DIR / f"{i + 1}.mp4"
|
|
video_clip.write_videofile(str(output_path), codec="libx264", audio_codec="aac")
|
|
logger.info("문제 %s 오디오 합성 완료: %s", i + 1, output_path)
|
|
|
|
|
|
def create_Final_Video():
|
|
base_folder = get_base_video_dir()
|
|
ensure_directory(FINAL_DIR)
|
|
|
|
for i in range(Prablem_No):
|
|
quiz_clip_path = QUIZ_DIR / f"{i + 1}.mp4"
|
|
base_clip_path = base_folder / f"{i + 1}.mp4"
|
|
logger.info("문제 %s 배경 합성 시작 | quiz=%s | base=%s", i + 1, quiz_clip_path, base_clip_path)
|
|
video_clip = VideoFileClip(str(quiz_clip_path))
|
|
source_clip = VideoFileClip(str(base_clip_path))
|
|
|
|
overlay_start_time = 7.5
|
|
overlay_clip = (
|
|
video_clip.set_start(overlay_start_time)
|
|
.set_position((540, 50))
|
|
.resize(width=1350, height=904.4)
|
|
.set_duration(video_clip.duration)
|
|
)
|
|
blurred_clip = overlay_clip.fadein(duration=1).fadeout(duration=1)
|
|
final_clip = CompositeVideoClip([source_clip, blurred_clip.set_duration(source_clip.duration - overlay_start_time - 1)])
|
|
|
|
output_path = FINAL_DIR / f"{i + 1}.mp4"
|
|
final_clip.write_videofile(str(output_path))
|
|
logger.info("문제 %s 배경 합성 완료: %s", i + 1, output_path)
|
|
|
|
|
|
def videos_Sum():
|
|
ensure_directory(FINAL_MERGED_DIR)
|
|
output_directory = FINAL_MERGED_DIR
|
|
today = datetime.today()
|
|
base_filename = today.strftime("%Y-%m-%d")
|
|
output_file = output_directory / f"{base_filename}-1.mp4"
|
|
|
|
count = 1
|
|
while output_file.exists():
|
|
count += 1
|
|
output_file = output_directory / f"{base_filename}-{count}.mp4"
|
|
|
|
video_clips = []
|
|
intro_file = get_videos_dir() / "인트로.mp4"
|
|
ending_file = get_videos_dir() / "엔딩.mp4"
|
|
video_clips.append(VideoFileClip(str(intro_file)))
|
|
logger.info("인트로 추가: %s", intro_file)
|
|
|
|
for i in range(Prablem_No):
|
|
quiz_file = WITH_AUDIO_DIR / f"{i + 1}.mp4"
|
|
video_clips.append(VideoFileClip(str(quiz_file)))
|
|
logger.info("최종 병합에 문제 추가: %s", quiz_file)
|
|
|
|
video_clips.append(VideoFileClip(str(ending_file)))
|
|
logger.info("엔딩 추가: %s", ending_file)
|
|
|
|
final_clip = concatenate_videoclips(video_clips)
|
|
final_clip.write_videofile(str(output_file))
|
|
logger.info("최종 병합 영상 생성 완료: %s", output_file)
|
|
|
|
|
|
def main():
|
|
app = QApplication(sys.argv)
|
|
ui = QuizMakerUI()
|
|
ui.show()
|
|
sys.exit(app.exec_())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|