Files
00-git-rules/README.md
T
2026-06-20 12:46:39 +09:00

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()