Upload 7 files
Browse files- .gitattributes +1 -0
- audio_processor.py +161 -0
- job_manager.py +213 -0
- logo.jpg +3 -0
- main.py +695 -0
- requirements.txt +1 -0
- source_matcher.py +365 -0
- video_generator_module.py +1040 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
logo.jpg filter=lfs diff=lfs merge=lfs -text
|
audio_processor.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Optional
|
| 4 |
+
from pydub import AudioSegment
|
| 5 |
+
from pydub.effects import normalize
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class AudioProcessor:
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.supported_formats = ['mp3', 'wav', 'm4a', 'aac', 'ogg']
|
| 13 |
+
|
| 14 |
+
async def combine_audios(
|
| 15 |
+
self,
|
| 16 |
+
audio_paths: List[str],
|
| 17 |
+
background_music_path: Optional[str] = None,
|
| 18 |
+
output_dir: str = "temp",
|
| 19 |
+
background_volume: float = 0.3,
|
| 20 |
+
fade_duration: int = 1000 # milliseconds
|
| 21 |
+
) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Tổng hợp các file audio thành một file duy nhất
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
audio_paths: Danh sách đường dẫn audio các cảnh
|
| 27 |
+
background_music_path: Đường dẫn nhạc nền (optional)
|
| 28 |
+
output_dir: Thư mục output
|
| 29 |
+
background_volume: Âm lượng nhạc nền (0.0 - 1.0)
|
| 30 |
+
fade_duration: Thời gian fade in/out (ms)
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
Đường dẫn file audio đã tổng hợp
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
logger.info(f"Bắt đầu tổng hợp {len(audio_paths)} file audio")
|
| 37 |
+
|
| 38 |
+
# Kiểm tra file input
|
| 39 |
+
valid_audio_paths = []
|
| 40 |
+
for path in audio_paths:
|
| 41 |
+
if os.path.exists(path):
|
| 42 |
+
valid_audio_paths.append(path)
|
| 43 |
+
logger.info(f"Audio hợp lệ: {path}")
|
| 44 |
+
else:
|
| 45 |
+
logger.warning(f"Audio không tồn tại: {path}")
|
| 46 |
+
|
| 47 |
+
if not valid_audio_paths:
|
| 48 |
+
raise ValueError("Không có file audio hợp lệ nào")
|
| 49 |
+
|
| 50 |
+
# Load và ghép các audio cảnh
|
| 51 |
+
combined_audio = None
|
| 52 |
+
total_duration = 0
|
| 53 |
+
|
| 54 |
+
for i, audio_path in enumerate(valid_audio_paths):
|
| 55 |
+
logger.info(f"Xử lý audio {i+1}/{len(valid_audio_paths)}: {audio_path}")
|
| 56 |
+
|
| 57 |
+
# Load audio với format tự động detect
|
| 58 |
+
audio_segment = AudioSegment.from_file(audio_path)
|
| 59 |
+
|
| 60 |
+
# Normalize audio
|
| 61 |
+
audio_segment = normalize(audio_segment)
|
| 62 |
+
|
| 63 |
+
# Thêm fade in/out cho audio đầu và cuối
|
| 64 |
+
if i == 0: # Audio đầu tiên
|
| 65 |
+
audio_segment = audio_segment.fade_in(fade_duration)
|
| 66 |
+
if i == len(valid_audio_paths) - 1: # Audio cuối cùng
|
| 67 |
+
audio_segment = audio_segment.fade_out(fade_duration)
|
| 68 |
+
|
| 69 |
+
# Ghép audio
|
| 70 |
+
if combined_audio is None:
|
| 71 |
+
combined_audio = audio_segment
|
| 72 |
+
else:
|
| 73 |
+
combined_audio = combined_audio + audio_segment
|
| 74 |
+
|
| 75 |
+
total_duration += len(audio_segment)
|
| 76 |
+
logger.info(f"Đã thêm audio {i+1}, tổng thời lượng: {total_duration/1000:.2f}s")
|
| 77 |
+
|
| 78 |
+
logger.info(f"Hoàn thành ghép audio cảnh, tổng thời lượng: {total_duration/1000:.2f}s")
|
| 79 |
+
|
| 80 |
+
# Thêm nhạc nền nếu có
|
| 81 |
+
if background_music_path and os.path.exists(background_music_path):
|
| 82 |
+
logger.info("Đang thêm nhạc nền...")
|
| 83 |
+
|
| 84 |
+
# Load nhạc nền
|
| 85 |
+
background_music = AudioSegment.from_file(background_music_path)
|
| 86 |
+
|
| 87 |
+
# Điều chỉnh âm lượng nhạc nền
|
| 88 |
+
background_music = background_music - (20 - int(background_volume * 20)) # Giảm dB
|
| 89 |
+
|
| 90 |
+
# Lặp lại nhạc nền nếu cần
|
| 91 |
+
if len(background_music) < len(combined_audio):
|
| 92 |
+
# Tính số lần lặp cần thiết
|
| 93 |
+
repeat_times = (len(combined_audio) // len(background_music)) + 1
|
| 94 |
+
background_music = background_music * repeat_times
|
| 95 |
+
|
| 96 |
+
# Cắt nhạc nền cho khớp với audio chính
|
| 97 |
+
background_music = background_music[:len(combined_audio)]
|
| 98 |
+
|
| 99 |
+
# Thêm fade in/out cho nhạc nền
|
| 100 |
+
background_music = background_music.fade_in(fade_duration * 2).fade_out(fade_duration * 2)
|
| 101 |
+
|
| 102 |
+
# Mix audio chính với nhạc nền
|
| 103 |
+
combined_audio = combined_audio.overlay(background_music)
|
| 104 |
+
logger.info("Đã thêm nhạc nền thành công")
|
| 105 |
+
|
| 106 |
+
# Tạo tên file output
|
| 107 |
+
output_filename = f"combined_audio_{uuid.uuid4().hex[:8]}.wav"
|
| 108 |
+
output_path = os.path.join(output_dir, output_filename)
|
| 109 |
+
|
| 110 |
+
# Export file kết quả
|
| 111 |
+
combined_audio.export(
|
| 112 |
+
output_path,
|
| 113 |
+
format="wav",
|
| 114 |
+
parameters=["-ac", "2", "-ar", "44100"] # Stereo, 44.1kHz
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
logger.info(f"Đã xuất file audio tổng hợp: {output_path}")
|
| 118 |
+
logger.info(f"Thời lượng cuối cùng: {len(combined_audio)/1000:.2f}s")
|
| 119 |
+
|
| 120 |
+
return output_path
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"Lỗi khi tổng hợp audio: {str(e)}")
|
| 124 |
+
raise
|
| 125 |
+
|
| 126 |
+
def get_audio_info(self, audio_path: str) -> dict:
|
| 127 |
+
"""
|
| 128 |
+
Lấy thông tin về file audio
|
| 129 |
+
"""
|
| 130 |
+
try:
|
| 131 |
+
audio = AudioSegment.from_file(audio_path)
|
| 132 |
+
return {
|
| 133 |
+
"duration_seconds": len(audio) / 1000,
|
| 134 |
+
"channels": audio.channels,
|
| 135 |
+
"frame_rate": audio.frame_rate,
|
| 136 |
+
"sample_width": audio.sample_width,
|
| 137 |
+
"file_size_mb": os.path.getsize(audio_path) / (1024 * 1024)
|
| 138 |
+
}
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"Lỗi khi lấy thông tin audio {audio_path}: {str(e)}")
|
| 141 |
+
return {}
|
| 142 |
+
|
| 143 |
+
def validate_audio_file(self, audio_path: str) -> bool:
|
| 144 |
+
"""
|
| 145 |
+
Kiểm tra tính hợp lệ của file audio
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
if not os.path.exists(audio_path):
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
# Kiểm tra extension
|
| 152 |
+
ext = audio_path.lower().split('.')[-1]
|
| 153 |
+
if ext not in self.supported_formats:
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
# Thử load file
|
| 157 |
+
AudioSegment.from_file(audio_path)
|
| 158 |
+
return True
|
| 159 |
+
|
| 160 |
+
except Exception:
|
| 161 |
+
return False
|
job_manager.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# job_manager.py
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, List, Optional
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
import queue
|
| 10 |
+
import asyncio
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class JobManager:
|
| 15 |
+
def __init__(self, jobs_file: str = "jobs.json"):
|
| 16 |
+
self.jobs_file = jobs_file
|
| 17 |
+
self.jobs: Dict = {}
|
| 18 |
+
self.lock = threading.Lock()
|
| 19 |
+
|
| 20 |
+
# --- Thêm cơ chế save bất đồng bộ ---
|
| 21 |
+
self._save_queue = queue.Queue()
|
| 22 |
+
self._save_thread = threading.Thread(target=self._save_worker, daemon=True)
|
| 23 |
+
self._save_thread.start()
|
| 24 |
+
self._pending_save = False # Flag để tránh queue quá nhiều save request
|
| 25 |
+
# ------------------------------------
|
| 26 |
+
|
| 27 |
+
# Load existing jobs
|
| 28 |
+
self._load_jobs()
|
| 29 |
+
# Cleanup old jobs
|
| 30 |
+
self._cleanup_old_jobs()
|
| 31 |
+
|
| 32 |
+
def _load_jobs(self):
|
| 33 |
+
"""Load jobs từ file"""
|
| 34 |
+
try:
|
| 35 |
+
if os.path.exists(self.jobs_file):
|
| 36 |
+
with open(self.jobs_file, 'r', encoding='utf-8') as f:
|
| 37 |
+
self.jobs = json.load(f)
|
| 38 |
+
logger.info(f"Loaded {len(self.jobs)} jobs from {self.jobs_file}")
|
| 39 |
+
else:
|
| 40 |
+
self.jobs = {}
|
| 41 |
+
logger.info("No existing jobs file, starting fresh")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Lỗi load jobs: {e}")
|
| 44 |
+
self.jobs = {}
|
| 45 |
+
|
| 46 |
+
def _save_jobs(self):
|
| 47 |
+
"""Save jobs ra file - phiên bản blocking cũ, giờ được gọi bởi worker thread"""
|
| 48 |
+
try:
|
| 49 |
+
# logger.info("_save_jobs (blocking version) được gọi bởi worker thread.") # Có thể bật log để debug
|
| 50 |
+
# Luôn cần lock khi truy cập self.jobs để đọc dữ liệu cần lưu
|
| 51 |
+
with self.lock:
|
| 52 |
+
# Tạo một bản sao dữ liệu để ghi, giảm thời gian giữ lock
|
| 53 |
+
jobs_to_save = self.jobs.copy()
|
| 54 |
+
# Thực hiện I/O bên ngoài lock để không block các thao tác khác
|
| 55 |
+
with open(self.jobs_file, 'w', encoding='utf-8') as f:
|
| 56 |
+
json.dump(jobs_to_save, f, indent=2, ensure_ascii=False)
|
| 57 |
+
logger.info("Jobs saved to file successfully.")
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Lỗi save jobs: {e}")
|
| 60 |
+
|
| 61 |
+
def _request_save(self):
|
| 62 |
+
"""Yêu cầu lưu jobs, nhưng không thực hiện ngay lập tức"""
|
| 63 |
+
# Tránh việc queue quá nhiều yêu cầu save nếu các thay đổi diễn ra nhanh
|
| 64 |
+
if not self._pending_save:
|
| 65 |
+
self._pending_save = True
|
| 66 |
+
# logger.info("Put save request to queue") # Có thể bật log để debug
|
| 67 |
+
self._save_queue.put(True) # Gửi tín hiệu yêu cầu save
|
| 68 |
+
|
| 69 |
+
def _save_worker(self):
|
| 70 |
+
"""Worker thread để xử lý các yêu cầu save"""
|
| 71 |
+
while True:
|
| 72 |
+
try:
|
| 73 |
+
# Chờ tín hiệu yêu cầu save
|
| 74 |
+
_ = self._save_queue.get(timeout=1) # Timeout để có thể kiểm tra vòng lặp
|
| 75 |
+
self._pending_save = False # Reset flag khi bắt đầu xử lý
|
| 76 |
+
# logger.info("Worker nhận được yêu cầu save, đang thực hiện...") # Có thể bật log để debug
|
| 77 |
+
self._save_jobs() # Gọi hàm save blocking thực tế
|
| 78 |
+
self._save_queue.task_done()
|
| 79 |
+
except queue.Empty:
|
| 80 |
+
# Timeout, tiếp tục vòng lặp
|
| 81 |
+
continue
|
| 82 |
+
except Exception as e:
|
| 83 |
+
self._pending_save = False # Đảm bảo flag được reset ngay cả khi lỗi
|
| 84 |
+
logger.error(f"Error in save worker: {e}")
|
| 85 |
+
|
| 86 |
+
# --- Các hàm public ---
|
| 87 |
+
|
| 88 |
+
def create_job(self, job_id: str, job_data: Dict):
|
| 89 |
+
"""
|
| 90 |
+
Tạo job mới - Phiên bản nhanh, không blocking I/O
|
| 91 |
+
"""
|
| 92 |
+
try:
|
| 93 |
+
logger.info(f"[{job_id}] create_job được gọi.")
|
| 94 |
+
with self.lock:
|
| 95 |
+
logger.info(f"[{job_id}] Đã có lock. Đang thêm job vào bộ nhớ...")
|
| 96 |
+
self.jobs[job_id] = job_data
|
| 97 |
+
logger.info(f"[{job_id}] Đã thêm vào bộ nhớ. Đang yêu cầu save (async)...")
|
| 98 |
+
# Thay vì gọi self._save_jobs() trực tiếp (gây blocking),
|
| 99 |
+
# ta chỉ yêu cầu worker thread làm điều đó.
|
| 100 |
+
self._request_save()
|
| 101 |
+
# Không đợi save xong, trả về ngay!
|
| 102 |
+
logger.info(f"[{job_id}] Created job thành công (chưa chắc file đã lưu).")
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"[{job_id}] Lỗi create job: {e}", exc_info=True)
|
| 105 |
+
raise
|
| 106 |
+
|
| 107 |
+
def update_job(self, job_id: str, updates: Dict):
|
| 108 |
+
"""
|
| 109 |
+
Cập nhật job - Phiên bản nhanh, không blocking I/O
|
| 110 |
+
"""
|
| 111 |
+
try:
|
| 112 |
+
with self.lock:
|
| 113 |
+
if job_id in self.jobs:
|
| 114 |
+
self.jobs[job_id].update(updates)
|
| 115 |
+
self.jobs[job_id]['updated_at'] = datetime.now().isoformat()
|
| 116 |
+
# Log progress updates
|
| 117 |
+
if 'progress' in updates:
|
| 118 |
+
logger.info(f"Job {job_id}: {updates.get('message', '')} ({updates['progress']}%)")
|
| 119 |
+
# Yêu cầu save async
|
| 120 |
+
logger.info(f"[{job_id}] Đang yêu cầu save update (async)...")
|
| 121 |
+
self._request_save()
|
| 122 |
+
else:
|
| 123 |
+
logger.warning(f"Job {job_id} not found for update")
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error(f"Lỗi update job {job_id}: {e}")
|
| 126 |
+
|
| 127 |
+
def get_job(self, job_id: str) -> Optional[Dict]:
|
| 128 |
+
"""Lấy thông tin job - Vẫn đồng bộ vì cần trả về dữ liệu ngay"""
|
| 129 |
+
# Truy cập đọc thường không cần lock nếu không có write đồng thời,
|
| 130 |
+
# nhưng để an toàn và nhất quán, có thể dùng lock.
|
| 131 |
+
with self.lock:
|
| 132 |
+
return self.jobs.get(job_id)
|
| 133 |
+
|
| 134 |
+
def list_jobs(self, status: Optional[str] = None, limit: int = 20) -> List[Dict]:
|
| 135 |
+
"""Lấy danh sách jobs - Vẫn đồng bộ"""
|
| 136 |
+
try:
|
| 137 |
+
with self.lock:
|
| 138 |
+
jobs_list = list(self.jobs.values()) # Sao chép để an toàn khi sort/filter
|
| 139 |
+
# Filter by status
|
| 140 |
+
if status:
|
| 141 |
+
jobs_list = [job for job in jobs_list if job.get('status') == status]
|
| 142 |
+
# Sort by created_at desc
|
| 143 |
+
jobs_list.sort(key=lambda x: x.get('created_at', ''), reverse=True)
|
| 144 |
+
# Limit results
|
| 145 |
+
return jobs_list[:limit]
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Lỗi list jobs: {e}")
|
| 148 |
+
return []
|
| 149 |
+
|
| 150 |
+
def delete_job(self, job_id: str) -> bool:
|
| 151 |
+
"""
|
| 152 |
+
Xóa job - Nhanh, không blocking I/O
|
| 153 |
+
"""
|
| 154 |
+
try:
|
| 155 |
+
with self.lock:
|
| 156 |
+
if job_id in self.jobs:
|
| 157 |
+
del self.jobs[job_id]
|
| 158 |
+
logger.info(f"Deleted job {job_id} from memory. Đang yêu cầu save (async)...")
|
| 159 |
+
self._request_save()
|
| 160 |
+
return True
|
| 161 |
+
return False
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"Lỗi delete job {job_id}: {e}")
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
def _cleanup_old_jobs(self, days_old: int = 7):
|
| 167 |
+
"""Xóa jobs cũ hơn n ngày - Có thể chạy định kỳ hoặc trong init"""
|
| 168 |
+
try:
|
| 169 |
+
cutoff_date = datetime.now() - timedelta(days=days_old)
|
| 170 |
+
jobs_to_remove = []
|
| 171 |
+
with self.lock: # Lock khi đọc để kiểm tra
|
| 172 |
+
for job_id, job_data in self.jobs.items():
|
| 173 |
+
try:
|
| 174 |
+
created_at = datetime.fromisoformat(job_data.get('created_at', ''))
|
| 175 |
+
if created_at < cutoff_date:
|
| 176 |
+
jobs_to_remove.append(job_id)
|
| 177 |
+
except:
|
| 178 |
+
jobs_to_remove.append(job_id)
|
| 179 |
+
|
| 180 |
+
if jobs_to_remove:
|
| 181 |
+
with self.lock: # Lock lại khi xóa
|
| 182 |
+
for job_id in jobs_to_remove:
|
| 183 |
+
if job_id in self.jobs: # Kiểm tra lại trong lock
|
| 184 |
+
del self.jobs[job_id]
|
| 185 |
+
logger.info(f"Cleaned up {len(jobs_to_remove)} old jobs from memory. Đang yêu cầu save (async)...")
|
| 186 |
+
self._request_save() # Yêu cầu lưu sau khi xóa
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"Lỗi cleanup old jobs: {e}")
|
| 189 |
+
|
| 190 |
+
def get_statistics(self) -> Dict:
|
| 191 |
+
"""Lấy thống kê jobs"""
|
| 192 |
+
try:
|
| 193 |
+
with self.lock:
|
| 194 |
+
total_jobs = len(self.jobs)
|
| 195 |
+
status_counts = {}
|
| 196 |
+
for job in self.jobs.values():
|
| 197 |
+
status = job.get('status', 'unknown')
|
| 198 |
+
status_counts[status] = status_counts.get(status, 0) + 1
|
| 199 |
+
return {
|
| 200 |
+
"total_jobs": total_jobs,
|
| 201 |
+
"status_breakdown": status_counts,
|
| 202 |
+
"last_updated": datetime.now().isoformat()
|
| 203 |
+
}
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"Lỗi get statistics: {e}")
|
| 206 |
+
return {}
|
| 207 |
+
|
| 208 |
+
# --- Thêm phương thức để shutdown worker thread nếu cần ---
|
| 209 |
+
def shutdown(self):
|
| 210 |
+
"""Dừng worker thread (nếu cần thiết trước khi thoát chương trình)"""
|
| 211 |
+
# Đơn giản nhất là để daemon thread tự dừng khi chương trình kết thúc.
|
| 212 |
+
# Hoặc có thể gửi một tín hiệu đặc biệt vào queue để worker thoát.
|
| 213 |
+
pass
|
logo.jpg
ADDED
|
Git LFS Details
|
main.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, BackgroundTasks
|
| 2 |
+
from fastapi.responses import FileResponse
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
from typing import List, Optional, Dict
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
import logging
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import shutil
|
| 10 |
+
from datetime import datetime
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
from enum import Enum
|
| 14 |
+
import requests # Thêm import requests
|
| 15 |
+
|
| 16 |
+
# Import các module tự tạo
|
| 17 |
+
from audio_processor import AudioProcessor
|
| 18 |
+
from source_matcher import SourceMatcher
|
| 19 |
+
from video_generator_module import generate_multiple_video_versions
|
| 20 |
+
from job_manager import JobManager
|
| 21 |
+
|
| 22 |
+
from dotenv import load_dotenv
|
| 23 |
+
import os
|
| 24 |
+
# Load biến môi trường từ .env
|
| 25 |
+
load_dotenv()
|
| 26 |
+
hf_token = os.getenv("HF_TOKEN")
|
| 27 |
+
|
| 28 |
+
# Header với Bearer token
|
| 29 |
+
headers = {
|
| 30 |
+
"Authorization": f"Bearer {hf_token}"
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
app = FastAPI(title="Video Generator API", version="1.0.0")
|
| 34 |
+
|
| 35 |
+
# Cấu hình logging
|
| 36 |
+
logging.basicConfig(level=logging.INFO)
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
# Khởi tạo các processor
|
| 40 |
+
audio_processor = AudioProcessor()
|
| 41 |
+
source_matcher = SourceMatcher(sources_root_dir="public")
|
| 42 |
+
job_manager = JobManager()
|
| 43 |
+
|
| 44 |
+
# Cấu hình đường dẫn
|
| 45 |
+
TEMP_DIR = "temp"
|
| 46 |
+
OUTPUT_DIR = "generated_video_output"
|
| 47 |
+
UPLOAD_DIR = "uploads"
|
| 48 |
+
PUBLIC_DIR = "public"
|
| 49 |
+
EXTERNAL_AUDIO_DIR = "external_audio" # Thư mục lưu audio tải từ API bên ngoài
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
ALLOWED_REFERENCE_AUDIOS = [
|
| 53 |
+
"CD team.wav", "nam tre khoe.wav", "nam-calm.wav", "nam-cham.wav",
|
| 54 |
+
"nam-nhanh.wav", "nam-tai-llieu.wav", "nam-truyen-cam.wav", "Nu nhe nhang.wav",
|
| 55 |
+
"nu-calm.wav", "nu-cham.wav", "nu-luu-loat.wav", "nu-nhan-nha.wav",
|
| 56 |
+
"nu-nhe-nhang.wav", "bongda1.wav"
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
# Hàm hỗ trợ để tạo URL cho file audio tham chiếu (giả định chúng được host trên GitHub hoặc server cục bộ)
|
| 60 |
+
# Bạn cần điều chỉnh BASE_REF_AUDIO_URL cho phù hợp với nơi lưu trữ file của bạn.
|
| 61 |
+
# Ví dụ nếu file nằm trong thư mục public/audio_refs/ thì có thể truy cập qua http://yourdomain.com/public/audio_refs/
|
| 62 |
+
BASE_REF_AUDIO_URL = "https://github.com/TruongSinhAI/vitts/raw/refs/heads/main/" # Cần thay đổi cho phù hợp
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Tạo các thư mục cần thiết
|
| 66 |
+
for dir_path in [TEMP_DIR, OUTPUT_DIR, UPLOAD_DIR, PUBLIC_DIR, EXTERNAL_AUDIO_DIR]:
|
| 67 |
+
os.makedirs(dir_path, exist_ok=True)
|
| 68 |
+
|
| 69 |
+
class JobStatus(str, Enum):
|
| 70 |
+
PENDING = "pending"
|
| 71 |
+
PROCESSING = "processing"
|
| 72 |
+
COMPLETED = "completed"
|
| 73 |
+
FAILED = "failed"
|
| 74 |
+
|
| 75 |
+
# Cập nhật SceneData để nhận text thay vì audio_path trực tiếp
|
| 76 |
+
class SceneData(BaseModel):
|
| 77 |
+
# Ưu tiên sử dụng 'text' để tạo audio. Nếu 'text' trống, có thể dùng 'audio_path' (tùy chọn).
|
| 78 |
+
text: str
|
| 79 |
+
keywords: List[str]
|
| 80 |
+
# Giữ lại audio_path để tương thích ngược (tùy chọn)
|
| 81 |
+
audio_path: Optional[str] = None
|
| 82 |
+
|
| 83 |
+
class VideoRequest(BaseModel):
|
| 84 |
+
title: str
|
| 85 |
+
scenes: List[SceneData]
|
| 86 |
+
background_music_path: Optional[str] = None
|
| 87 |
+
thumbnail_path: Optional[str] = "thumbnails/thumbnail1.png"
|
| 88 |
+
logo_path: Optional[str] = ''
|
| 89 |
+
aspect_ratio: str = "doc"
|
| 90 |
+
num_versions: int = 1
|
| 91 |
+
target_height: int = 1080
|
| 92 |
+
reference_audio_file: Optional[str] = "https://github.com/TruongSinhAI/vitts/raw/refs/heads/main/bongda1.wav"
|
| 93 |
+
|
| 94 |
+
# Tên file trong danh sách được liệt kê, ví dụ: "bongda1.wav"
|
| 95 |
+
|
| 96 |
+
class JobResponse(BaseModel):
|
| 97 |
+
job_id: str
|
| 98 |
+
status: JobStatus
|
| 99 |
+
message: str
|
| 100 |
+
progress: int = 0
|
| 101 |
+
estimated_time_remaining: Optional[int] = None
|
| 102 |
+
created_at: str
|
| 103 |
+
video_paths: Optional[List[str]] = None
|
| 104 |
+
error: Optional[str] = None
|
| 105 |
+
|
| 106 |
+
# --- Hàm mới để gọi API chuyển text thành audio ---
|
| 107 |
+
import json # Đảm bảo import json
|
| 108 |
+
|
| 109 |
+
async def text_to_audio(text: str, language: str = "vi", job_id: str = "unknown", reference_audio_file: Optional[str] = "https://github.com/TruongSinhAI/vitts/raw/refs/heads/main/bongda1.wav") -> str:
|
| 110 |
+
"""Gọi API Hugging Face để chuyển text thành audio và lưu file tạm."""
|
| 111 |
+
try:
|
| 112 |
+
logger.info(f"[Job {job_id}] Chuyển text sang audio: '{text[:50]}...'")
|
| 113 |
+
|
| 114 |
+
# Payload gửi đi (giữ nguyên như cũ)
|
| 115 |
+
payload = {
|
| 116 |
+
"data": [
|
| 117 |
+
text,
|
| 118 |
+
language,
|
| 119 |
+
{"path": reference_audio_file},
|
| 120 |
+
True
|
| 121 |
+
]
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Gửi yêu cầu
|
| 125 |
+
# Cập nhật URL API
|
| 126 |
+
r = requests.post("https://thinhlpg-vixtts-demo.hf.space/call/predict", headers=headers,json=payload)
|
| 127 |
+
r.raise_for_status()
|
| 128 |
+
event_id = r.json().get("event_id")
|
| 129 |
+
if not event_id:
|
| 130 |
+
raise Exception("Không nhận được event_id từ API Hugging Face.")
|
| 131 |
+
|
| 132 |
+
logger.info(f"[Job {job_id}] Nhận được event_id: {event_id}")
|
| 133 |
+
|
| 134 |
+
# Chờ kết quả - Cập nhật URL
|
| 135 |
+
result_url = f"https://thinhlpg-vixtts-demo.hf.space/call/predict/{event_id}"
|
| 136 |
+
|
| 137 |
+
# --- Sử dụng phương thức lấy kết quả mới ---
|
| 138 |
+
response = requests.get(result_url,headers=headers, stream=True)
|
| 139 |
+
response.raise_for_status() # Kiểm tra lỗi HTTP sớm
|
| 140 |
+
|
| 141 |
+
result_line = '' # Biến lưu dòng kết quả cuối cùng
|
| 142 |
+
# Lấy kết quả - Lặp qua từng dòng
|
| 143 |
+
for line in response.iter_lines():
|
| 144 |
+
if line:
|
| 145 |
+
decoded_line = line.decode('utf-8')
|
| 146 |
+
logger.debug(f"[Job {job_id}] Dòng phản hồi từ API: {decoded_line}")
|
| 147 |
+
result_line = decoded_line # Cập nhật dòng kết quả
|
| 148 |
+
|
| 149 |
+
if not result_line:
|
| 150 |
+
raise Exception("Không nhận được dữ liệu từ phản hồi API.")
|
| 151 |
+
|
| 152 |
+
logger.info(f"[Job {job_id}] Dữ liệu phản hồi cuối cùng: {result_line}")
|
| 153 |
+
|
| 154 |
+
# --- Phân tích phản hồi để lấy URL ---
|
| 155 |
+
# Giả định result_line chứa JSON hợp lệ
|
| 156 |
+
try:
|
| 157 |
+
audio_url = result_line.split('"url": "')[1].split('",')[0].replace("/c/file", "/file")
|
| 158 |
+
if not audio_url:
|
| 159 |
+
# Nếu không tìm thấy theo cách trên, thử phân tích chuỗi như trong ví dụ của bạn
|
| 160 |
+
# (Đây là cách fallback kém ổn định hơn)
|
| 161 |
+
if '"url": "' in result_line:
|
| 162 |
+
audio_url = result_line.split('"url": "')[1].split('",')[0]
|
| 163 |
+
# Xử lý URL nếu cần (ví dụ: thay thế path)
|
| 164 |
+
audio_url = audio_url.replace("/c/file", "/file")
|
| 165 |
+
else:
|
| 166 |
+
raise Exception("Không tìm thấy URL audio trong phản hồi.")
|
| 167 |
+
except json.JSONDecodeError:
|
| 168 |
+
# Nếu JSON không hợp lệ, thử phân tích chuỗi như ví dụ
|
| 169 |
+
logger.warning(f"[Job {job_id}] Phản hồi không phải JSON hợp lệ, thử phân tích chuỗi...")
|
| 170 |
+
if '"url": "' in result_line:
|
| 171 |
+
audio_url = result_line.split('"url": "')[1].split('",')[0]
|
| 172 |
+
audio_url = audio_url.replace("/c/file", "/file") # Xử lý URL
|
| 173 |
+
else:
|
| 174 |
+
raise Exception("Phản hồi không hợp lệ và không thể trích xuất URL.")
|
| 175 |
+
# --- Kết thúc phân tích phản hồi ---
|
| 176 |
+
|
| 177 |
+
if not audio_url:
|
| 178 |
+
raise Exception("Không thể xác định URL audio từ phản hồi API.")
|
| 179 |
+
|
| 180 |
+
logger.info(f"[Job {job_id}] URL audio: {audio_url}")
|
| 181 |
+
|
| 182 |
+
# --- Tải audio về ---
|
| 183 |
+
# Tải audio (sử dụng audio_url đã xử lý)
|
| 184 |
+
audio_response = requests.get(audio_url, stream=True)
|
| 185 |
+
audio_response.raise_for_status()
|
| 186 |
+
|
| 187 |
+
# Tạo tên file duy nhất trong thư mục EXTERNAL_AUDIO_DIR
|
| 188 |
+
audio_filename = f"audio_{job_id}_{uuid.uuid4().hex[:8]}.wav"
|
| 189 |
+
audio_path = os.path.join(EXTERNAL_AUDIO_DIR, audio_filename) # Sử dụng EXTERNAL_AUDIO_DIR
|
| 190 |
+
|
| 191 |
+
with open(audio_path, "wb") as f:
|
| 192 |
+
for chunk in audio_response.iter_content(chunk_size=8192):
|
| 193 |
+
if chunk:
|
| 194 |
+
f.write(chunk)
|
| 195 |
+
|
| 196 |
+
logger.info(f"[Job {job_id}] Đã tải audio về: {audio_path}")
|
| 197 |
+
return audio_path
|
| 198 |
+
# --- Kết thúc tải audio ---
|
| 199 |
+
|
| 200 |
+
except requests.exceptions.RequestException as e:
|
| 201 |
+
logger.error(f"[Job {job_id}] Lỗi mạng khi gọi API Hugging Face: {e}")
|
| 202 |
+
raise Exception(f"Lỗi mạng khi gọi API chuyển text sang audio: {e}")
|
| 203 |
+
except Exception as e:
|
| 204 |
+
logger.error(f"[Job {job_id}] Lỗi khi chuyển text sang audio: {e}")
|
| 205 |
+
raise e # Ném lại lỗi để xử lý ở cấp cao hơn
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# Hàm nền để chạy async task trong executor nếu cần
|
| 210 |
+
def generate_video_background_wrapper(job_id: str, request: VideoRequest):
|
| 211 |
+
import asyncio
|
| 212 |
+
asyncio.run(generate_video_background(job_id, request))
|
| 213 |
+
|
| 214 |
+
async def generate_video_background(job_id: str, request: VideoRequest):
|
| 215 |
+
"""
|
| 216 |
+
Background task để tạo video
|
| 217 |
+
"""
|
| 218 |
+
try:
|
| 219 |
+
# Cập nhật status thành processing
|
| 220 |
+
job_manager.update_job(job_id, {
|
| 221 |
+
"status": JobStatus.PROCESSING,
|
| 222 |
+
"message": "Bắt đầu xử lý video...",
|
| 223 |
+
"progress": 0
|
| 224 |
+
})
|
| 225 |
+
temp_request_dir = os.path.join(TEMP_DIR, job_id)
|
| 226 |
+
os.makedirs(temp_request_dir, exist_ok=True)
|
| 227 |
+
logger.info(f"Background job {job_id}: Bắt đầu xử lý")
|
| 228 |
+
|
| 229 |
+
# --- Bước 0: Gộp tất cả text và keywords từ các scene ---
|
| 230 |
+
job_manager.update_job(job_id, {
|
| 231 |
+
"message": "Đang gộp nội dung từ các scene...",
|
| 232 |
+
"progress": 2,
|
| 233 |
+
"estimated_time_remaining": 600
|
| 234 |
+
})
|
| 235 |
+
|
| 236 |
+
# Gộp tất cả text từ các scene
|
| 237 |
+
combined_text_parts = []
|
| 238 |
+
all_keywords = []
|
| 239 |
+
|
| 240 |
+
for i, scene in enumerate(request.scenes):
|
| 241 |
+
if scene.text.strip():
|
| 242 |
+
combined_text_parts.append(scene.text.strip())
|
| 243 |
+
# Gộp tất cả keywords
|
| 244 |
+
all_keywords.extend(scene.keywords)
|
| 245 |
+
|
| 246 |
+
# Kiểm tra có text để xử lý không
|
| 247 |
+
if not combined_text_parts:
|
| 248 |
+
raise ValueError("Không có text nào để chuyển thành audio từ tất cả các scene")
|
| 249 |
+
|
| 250 |
+
# Gộp text thành một đoạn dài, ngăn cách bằng dấu chấm và khoảng trống
|
| 251 |
+
combined_text = ". ".join(combined_text_parts)
|
| 252 |
+
|
| 253 |
+
# Loại bỏ keywords trùng lặp và giữ thứ tự
|
| 254 |
+
unique_keywords = list(dict.fromkeys(all_keywords)) # Removes duplicates while preserving order
|
| 255 |
+
|
| 256 |
+
logger.info(f"[Job {job_id}] Đã gộp text từ {len(request.scenes)} scene(s): '{combined_text[:100]}...'")
|
| 257 |
+
logger.info(f"[Job {job_id}] Đã gộp {len(unique_keywords)} keywords duy nhất: {unique_keywords[:10]}...")
|
| 258 |
+
|
| 259 |
+
# --- Bước 1: Chuyển text gộp thành audio (một lần duy nhất) ---
|
| 260 |
+
job_manager.update_job(job_id, {
|
| 261 |
+
"message": "Đang chuyển text gộp thành audio...",
|
| 262 |
+
"progress": 5,
|
| 263 |
+
"estimated_time_remaining": 580
|
| 264 |
+
})
|
| 265 |
+
|
| 266 |
+
# Gọi hàm async để chuyển text gộp thành audio
|
| 267 |
+
combined_audio_path = await text_to_audio(
|
| 268 |
+
combined_text,
|
| 269 |
+
language="vi",
|
| 270 |
+
job_id=job_id,
|
| 271 |
+
reference_audio_file=request.reference_audio_file # Truyền tham số mới
|
| 272 |
+
)
|
| 273 |
+
temp_audio_files = [combined_audio_path]
|
| 274 |
+
|
| 275 |
+
# --- Bước 2: Tổng hợp audio với background music (nếu có) ---
|
| 276 |
+
job_manager.update_job(job_id, {
|
| 277 |
+
"message": "Đang tổng hợp audio với nhạc nền...",
|
| 278 |
+
"progress": 15,
|
| 279 |
+
"estimated_time_remaining": 480
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
# Nếu có background music, combine với audio đã tạo
|
| 283 |
+
if request.background_music_path:
|
| 284 |
+
final_audio_path = await audio_processor.combine_audios(
|
| 285 |
+
audio_paths=[combined_audio_path],
|
| 286 |
+
background_music_path=request.background_music_path,
|
| 287 |
+
output_dir=temp_request_dir
|
| 288 |
+
)
|
| 289 |
+
else:
|
| 290 |
+
# Nếu không có background music, sử dụng audio gốc
|
| 291 |
+
final_audio_path = combined_audio_path
|
| 292 |
+
|
| 293 |
+
# --- Bước 3: Tìm source media (40% progress) ---
|
| 294 |
+
job_manager.update_job(job_id, {
|
| 295 |
+
"message": "Đang tìm source media phù hợp...",
|
| 296 |
+
"progress": 30,
|
| 297 |
+
"estimated_time_remaining": 420
|
| 298 |
+
})
|
| 299 |
+
|
| 300 |
+
# Sử dụng keywords đã gộp để tìm source media
|
| 301 |
+
source_media_paths = await source_matcher.find_matching_sources(
|
| 302 |
+
keywords=unique_keywords,
|
| 303 |
+
title=request.title
|
| 304 |
+
)
|
| 305 |
+
if not source_media_paths:
|
| 306 |
+
source_media_paths = source_matcher.get_fallback_sources()
|
| 307 |
+
|
| 308 |
+
if not source_media_paths:
|
| 309 |
+
raise ValueError("Không tìm thấy source media nào")
|
| 310 |
+
|
| 311 |
+
# --- Bước 4: Tạo video (60% - 100% progress) ---
|
| 312 |
+
job_manager.update_job(job_id, {
|
| 313 |
+
"message": "Đang tạo video... (có thể mất 5-8 phút)",
|
| 314 |
+
"progress": 50,
|
| 315 |
+
"estimated_time_remaining": 300
|
| 316 |
+
})
|
| 317 |
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
| 318 |
+
|
| 319 |
+
# Tạo video với progress callback
|
| 320 |
+
def progress_callback(current_step: int, total_steps: int):
|
| 321 |
+
progress = 50 + int((current_step / total_steps) * 50)
|
| 322 |
+
remaining_time = max(0, int((total_steps - current_step) * 30)) # Ước tính 30s/step
|
| 323 |
+
job_manager.update_job(job_id, {
|
| 324 |
+
"message": f"Đang render video... ({current_step}/{total_steps})",
|
| 325 |
+
"progress": progress,
|
| 326 |
+
"estimated_time_remaining": remaining_time
|
| 327 |
+
})
|
| 328 |
+
|
| 329 |
+
# Giả sử generate_multiple_video_versions là blocking
|
| 330 |
+
# Cần chạy trong executor để không block event loop
|
| 331 |
+
loop = asyncio.get_event_loop()
|
| 332 |
+
video_paths = await loop.run_in_executor(
|
| 333 |
+
None, # Sử dụng default executor (ThreadPoolExecutor)
|
| 334 |
+
generate_multiple_video_versions,
|
| 335 |
+
final_audio_path, # Sử dụng audio đã combine
|
| 336 |
+
source_media_paths,
|
| 337 |
+
request.aspect_ratio,
|
| 338 |
+
request.num_versions,
|
| 339 |
+
f"video_{job_id}",
|
| 340 |
+
OUTPUT_DIR,
|
| 341 |
+
request.target_height,
|
| 342 |
+
request.thumbnail_path,
|
| 343 |
+
request.logo_path,
|
| 344 |
+
request.title,
|
| 345 |
+
current_date
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
# Hoàn thành (100%)
|
| 349 |
+
job_manager.update_job(job_id, {
|
| 350 |
+
"status": JobStatus.COMPLETED,
|
| 351 |
+
"message": "Tạo video thành công!",
|
| 352 |
+
"progress": 100,
|
| 353 |
+
"estimated_time_remaining": 0,
|
| 354 |
+
"video_paths": video_paths,
|
| 355 |
+
"completed_at": datetime.now().isoformat(),
|
| 356 |
+
"processing_summary": {
|
| 357 |
+
"total_scenes": len(request.scenes),
|
| 358 |
+
"combined_text_length": len(combined_text),
|
| 359 |
+
"unique_keywords_count": len(unique_keywords),
|
| 360 |
+
"audio_file": os.path.basename(final_audio_path)
|
| 361 |
+
}
|
| 362 |
+
})
|
| 363 |
+
|
| 364 |
+
# Cleanup temp files
|
| 365 |
+
shutil.rmtree(temp_request_dir, ignore_errors=True)
|
| 366 |
+
# Cleanup temp audio files (nếu có)
|
| 367 |
+
for temp_audio_file in temp_audio_files:
|
| 368 |
+
try:
|
| 369 |
+
os.remove(temp_audio_file)
|
| 370 |
+
logger.info(f"[Job {job_id}] Đã xóa file audio tạm: {temp_audio_file}")
|
| 371 |
+
except OSError as e:
|
| 372 |
+
logger.warning(f"[Job {job_id}] Không thể xóa file audio tạm {temp_audio_file}: {e}")
|
| 373 |
+
logger.info(f"Background job {job_id}: Hoàn thành thành công với {len(request.scenes)} scene(s) được gộp")
|
| 374 |
+
except Exception as e:
|
| 375 |
+
error_msg = str(e)
|
| 376 |
+
logger.error(f"Background job {job_id} failed: {error_msg}")
|
| 377 |
+
job_manager.update_job(job_id, {
|
| 378 |
+
"status": JobStatus.FAILED,
|
| 379 |
+
"message": f"Lỗi tạo video: {error_msg}",
|
| 380 |
+
"progress": 0,
|
| 381 |
+
"error": error_msg,
|
| 382 |
+
"failed_at": datetime.now().isoformat()
|
| 383 |
+
})
|
| 384 |
+
# Cleanup on error
|
| 385 |
+
temp_request_dir = os.path.join(TEMP_DIR, job_id)
|
| 386 |
+
if os.path.exists(temp_request_dir):
|
| 387 |
+
shutil.rmtree(temp_request_dir, ignore_errors=True)
|
| 388 |
+
# Cleanup temp audio files on error (nếu có)
|
| 389 |
+
if 'temp_audio_files' in locals():
|
| 390 |
+
for temp_audio_file in temp_audio_files:
|
| 391 |
+
try:
|
| 392 |
+
os.remove(temp_audio_file)
|
| 393 |
+
logger.info(f"[Job {job_id}] Đã xóa file audio tạm do lỗi: {temp_audio_file}")
|
| 394 |
+
except OSError as e:
|
| 395 |
+
logger.warning(f"[Job {job_id}] Không thể xóa file audio tạm do lỗi {temp_audio_file}: {e}")
|
| 396 |
+
|
| 397 |
+
# Trong /generate-video endpoint
|
| 398 |
+
@app.post("/generate-video", response_model=JobResponse)
|
| 399 |
+
async def generate_video(request: VideoRequest, background_tasks: BackgroundTasks):
|
| 400 |
+
"""
|
| 401 |
+
Tạo video từ title, text các cảnh, keywords và nhạc nền (Async)
|
| 402 |
+
Gộp tất cả text và keywords từ các scene để xử lý một lần
|
| 403 |
+
Trả về job_id để tracking progress
|
| 404 |
+
"""
|
| 405 |
+
job_id = None # Khai báo trước để có thể dùng trong except
|
| 406 |
+
try:
|
| 407 |
+
# Tạo job ID
|
| 408 |
+
job_id = str(uuid.uuid4())
|
| 409 |
+
logger.info(f"[{job_id}] Bắt đầu tạo job với {len(request.scenes)} scene(s)")
|
| 410 |
+
|
| 411 |
+
# Tạo job record với thông tin gộp
|
| 412 |
+
job_data = {
|
| 413 |
+
"job_id": job_id,
|
| 414 |
+
"status": JobStatus.PENDING,
|
| 415 |
+
"message": f"Job đã được tạo, đang chờ xử lý {len(request.scenes)} scene(s)...",
|
| 416 |
+
"progress": 0,
|
| 417 |
+
"created_at": datetime.now().isoformat(),
|
| 418 |
+
"request_data": request.model_dump(),
|
| 419 |
+
"estimated_time_remaining": 600, # 10 phút
|
| 420 |
+
"scenes_count": len(request.scenes)
|
| 421 |
+
}
|
| 422 |
+
logger.info(f"[{job_id}] Dữ liệu job: {job_data}")
|
| 423 |
+
|
| 424 |
+
# --- Thêm log trước và sau create_job ---
|
| 425 |
+
logger.info(f"[{job_id}] Đang gọi job_manager.create_job...")
|
| 426 |
+
job_manager.create_job(job_id, job_data)
|
| 427 |
+
logger.info(f"[{job_id}] job_manager.create_job hoàn tất.")
|
| 428 |
+
|
| 429 |
+
# --- Thêm log trước và sau add_task ---
|
| 430 |
+
logger.info(f"[{job_id}] Đang thêm background task...")
|
| 431 |
+
background_tasks.add_task(generate_video_background, job_id, request)
|
| 432 |
+
logger.info(f"[{job_id}] Background task đã được thêm.")
|
| 433 |
+
|
| 434 |
+
# --- Thêm log trước khi tạo response ---
|
| 435 |
+
logger.info(f"[{job_id}] Đang chuẩn bị JobResponse...")
|
| 436 |
+
response = JobResponse(
|
| 437 |
+
job_id=job_id,
|
| 438 |
+
status=JobStatus.PENDING,
|
| 439 |
+
message=f"Video đang được xử lý từ {len(request.scenes)} scene(s). Sử dụng job_id để kiểm tra tiến độ.",
|
| 440 |
+
progress=0,
|
| 441 |
+
estimated_time_remaining=600,
|
| 442 |
+
created_at=job_data["created_at"]
|
| 443 |
+
)
|
| 444 |
+
logger.info(f"[{job_id}] JobResponse đã được tạo: {response}")
|
| 445 |
+
logger.info(f"[{job_id}] Trả về phản hồi thành công.")
|
| 446 |
+
return response
|
| 447 |
+
except Exception as e:
|
| 448 |
+
error_msg = f"Lỗi tạo job {job_id if job_id else 'unknown'}: {str(e)}"
|
| 449 |
+
logger.error(error_msg)
|
| 450 |
+
raise HTTPException(status_code=500, detail=error_msg)
|
| 451 |
+
|
| 452 |
+
@app.get("/job-status/{job_id}", response_model=JobResponse)
|
| 453 |
+
async def get_job_status(job_id: str):
|
| 454 |
+
"""
|
| 455 |
+
Kiểm tra trạng thái job
|
| 456 |
+
"""
|
| 457 |
+
try:
|
| 458 |
+
job_data = job_manager.get_job(job_id)
|
| 459 |
+
if not job_data:
|
| 460 |
+
raise HTTPException(status_code=404, detail="Job không tồn tại")
|
| 461 |
+
return JobResponse(**job_data)
|
| 462 |
+
except HTTPException:
|
| 463 |
+
raise
|
| 464 |
+
except Exception as e:
|
| 465 |
+
logger.error(f"Lỗi lấy job status: {str(e)}")
|
| 466 |
+
raise HTTPException(status_code=500, detail=f"Lỗi lấy job status: {str(e)}")
|
| 467 |
+
|
| 468 |
+
@app.get("/jobs")
|
| 469 |
+
async def list_jobs(status: Optional[JobStatus] = None, limit: int = 20):
|
| 470 |
+
"""
|
| 471 |
+
Lấy danh sách jobs
|
| 472 |
+
"""
|
| 473 |
+
try:
|
| 474 |
+
jobs = job_manager.list_jobs(status=status, limit=limit)
|
| 475 |
+
return {
|
| 476 |
+
"success": True,
|
| 477 |
+
"jobs": jobs,
|
| 478 |
+
"total": len(jobs)
|
| 479 |
+
}
|
| 480 |
+
except Exception as e:
|
| 481 |
+
raise HTTPException(status_code=500, detail=f"Lỗi lấy danh sách jobs: {str(e)}")
|
| 482 |
+
|
| 483 |
+
@app.delete("/job/{job_id}")
|
| 484 |
+
async def cancel_job(job_id: str):
|
| 485 |
+
"""
|
| 486 |
+
Hủy job (chỉ work với job pending/processing)
|
| 487 |
+
"""
|
| 488 |
+
try:
|
| 489 |
+
job_data = job_manager.get_job(job_id)
|
| 490 |
+
if not job_data:
|
| 491 |
+
raise HTTPException(status_code=404, detail="Job không tồn tại")
|
| 492 |
+
if job_data["status"] in [JobStatus.COMPLETED, JobStatus.FAILED]:
|
| 493 |
+
raise HTTPException(status_code=400, detail="Không thể hủy job đã hoàn thành/thất bại")
|
| 494 |
+
|
| 495 |
+
# Ghi chú: Việc hủy thực sự task đang chạy trong background khó thực hiện trong Python.
|
| 496 |
+
# Việc cập nhật trạng thái là cách tiếp cận phổ biến.
|
| 497 |
+
job_manager.update_job(job_id, {
|
| 498 |
+
"status": JobStatus.FAILED,
|
| 499 |
+
"message": "Job đã bị hủy bởi user",
|
| 500 |
+
"cancelled_at": datetime.now().isoformat()
|
| 501 |
+
})
|
| 502 |
+
return {"success": True, "message": "Job đã được đánh dấu là hủy"}
|
| 503 |
+
except HTTPException:
|
| 504 |
+
raise
|
| 505 |
+
except Exception as e:
|
| 506 |
+
raise HTTPException(status_code=500, detail=f"Lỗi hủy job: {str(e)}")
|
| 507 |
+
|
| 508 |
+
@app.post("/upload-audio")
|
| 509 |
+
async def upload_audio(file: UploadFile = File(...)):
|
| 510 |
+
"""
|
| 511 |
+
Upload file audio cho từng cảnh
|
| 512 |
+
"""
|
| 513 |
+
try:
|
| 514 |
+
if not file.filename.lower().endswith(('.mp3', '.wav', '.m4a', '.aac', '.ogg')):
|
| 515 |
+
raise HTTPException(status_code=400, detail="Chỉ hỗ trợ file audio: mp3, wav, m4a, aac, ogg")
|
| 516 |
+
file_id = str(uuid.uuid4())
|
| 517 |
+
file_extension = os.path.splitext(file.filename)[1]
|
| 518 |
+
saved_filename = f"audio_{file_id}{file_extension}"
|
| 519 |
+
saved_path = os.path.join(UPLOAD_DIR, saved_filename)
|
| 520 |
+
with open(saved_path, "wb") as buffer:
|
| 521 |
+
shutil.copyfileobj(file.file, buffer)
|
| 522 |
+
audio_info = audio_processor.get_audio_info(saved_path)
|
| 523 |
+
return {
|
| 524 |
+
"success": True,
|
| 525 |
+
"filename": saved_filename,
|
| 526 |
+
"path": saved_path,
|
| 527 |
+
"original_name": file.filename,
|
| 528 |
+
"audio_info": audio_info
|
| 529 |
+
}
|
| 530 |
+
except Exception as e:
|
| 531 |
+
raise HTTPException(status_code=500, detail=f"Lỗi upload audio: {str(e)}")
|
| 532 |
+
|
| 533 |
+
@app.post("/upload-source")
|
| 534 |
+
async def upload_source(
|
| 535 |
+
file: UploadFile = File(...),
|
| 536 |
+
folder: str = Form("general") # Thư mục con trong public, mặc định là "general"
|
| 537 |
+
):
|
| 538 |
+
"""
|
| 539 |
+
Upload source media vào thư mục public
|
| 540 |
+
"""
|
| 541 |
+
try:
|
| 542 |
+
# Validate file extension
|
| 543 |
+
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.mp4', '.avi', '.mov', '.mkv'}
|
| 544 |
+
file_extension = os.path.splitext(file.filename)[1].lower()
|
| 545 |
+
if file_extension not in allowed_extensions:
|
| 546 |
+
raise HTTPException(
|
| 547 |
+
status_code=400,
|
| 548 |
+
detail=f"Chỉ hỗ trợ file: {', '.join(allowed_extensions)}"
|
| 549 |
+
)
|
| 550 |
+
# Tạo thư mục đích trong public
|
| 551 |
+
target_folder = os.path.join(PUBLIC_DIR, folder)
|
| 552 |
+
os.makedirs(target_folder, exist_ok=True)
|
| 553 |
+
# Tạo tên file duy nhất
|
| 554 |
+
file_id = str(uuid.uuid4())
|
| 555 |
+
saved_filename = f"source_{file_id}{file_extension}"
|
| 556 |
+
saved_path = os.path.join(target_folder, saved_filename)
|
| 557 |
+
# Lưu file
|
| 558 |
+
with open(saved_path, "wb") as buffer:
|
| 559 |
+
shutil.copyfileobj(file.file, buffer)
|
| 560 |
+
# Refresh source matcher để nhận file mới
|
| 561 |
+
source_matcher.refresh_sources()
|
| 562 |
+
return {
|
| 563 |
+
"success": True,
|
| 564 |
+
"filename": saved_filename,
|
| 565 |
+
"path": saved_path,
|
| 566 |
+
"folder": folder,
|
| 567 |
+
"original_name": file.filename,
|
| 568 |
+
"file_size_mb": round(os.path.getsize(saved_path) / (1024 * 1024), 2),
|
| 569 |
+
"message": f"File đã được upload vào thư mục '{folder}' trong public"
|
| 570 |
+
}
|
| 571 |
+
except HTTPException:
|
| 572 |
+
raise
|
| 573 |
+
except Exception as e:
|
| 574 |
+
raise HTTPException(status_code=500, detail=f"Lỗi upload source: {str(e)}")
|
| 575 |
+
|
| 576 |
+
@app.get("/download-video/{filename}")
|
| 577 |
+
async def download_video(filename: str):
|
| 578 |
+
"""
|
| 579 |
+
Download video đã tạo
|
| 580 |
+
"""
|
| 581 |
+
filename = filename.split('/')[-1]
|
| 582 |
+
logger.info(f"Filename: {filename}")
|
| 583 |
+
file_path = os.path.join(OUTPUT_DIR, filename)
|
| 584 |
+
if not os.path.exists(file_path):
|
| 585 |
+
raise HTTPException(status_code=404, detail="File không tồn tại")
|
| 586 |
+
return FileResponse(
|
| 587 |
+
path=file_path,
|
| 588 |
+
filename=filename,
|
| 589 |
+
media_type='video/mp4'
|
| 590 |
+
)
|
| 591 |
+
|
| 592 |
+
@app.get("/download-video/generated_video_output/{filename}")
|
| 593 |
+
async def download_video(filename: str):
|
| 594 |
+
"""
|
| 595 |
+
Download video đã tạo
|
| 596 |
+
"""
|
| 597 |
+
|
| 598 |
+
file_path = os.path.join(OUTPUT_DIR, filename)
|
| 599 |
+
if not os.path.exists(file_path):
|
| 600 |
+
raise HTTPException(status_code=404, detail="File không tồn tại")
|
| 601 |
+
return FileResponse(
|
| 602 |
+
path=file_path,
|
| 603 |
+
filename=filename,
|
| 604 |
+
media_type='video/mp4'
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
@app.get("/available-sources")
|
| 608 |
+
async def get_available_sources():
|
| 609 |
+
"""
|
| 610 |
+
Lấy danh sách các source folder có sẵn trong public
|
| 611 |
+
"""
|
| 612 |
+
try:
|
| 613 |
+
sources = await source_matcher.get_available_sources()
|
| 614 |
+
return {
|
| 615 |
+
"success": True,
|
| 616 |
+
"public_folder": PUBLIC_DIR,
|
| 617 |
+
"sources": sources
|
| 618 |
+
}
|
| 619 |
+
except Exception as e:
|
| 620 |
+
raise HTTPException(status_code=500, detail=f"Lỗi lấy danh sách sources: {str(e)}")
|
| 621 |
+
|
| 622 |
+
@app.post("/scan-sources")
|
| 623 |
+
async def scan_sources():
|
| 624 |
+
"""
|
| 625 |
+
Rescan folder public để cập nhật danh sách sources
|
| 626 |
+
"""
|
| 627 |
+
try:
|
| 628 |
+
source_matcher.refresh_sources()
|
| 629 |
+
sources = await source_matcher.get_available_sources()
|
| 630 |
+
return {
|
| 631 |
+
"success": True,
|
| 632 |
+
"message": "Đã quét lại folder public",
|
| 633 |
+
"sources": sources
|
| 634 |
+
}
|
| 635 |
+
except Exception as e:
|
| 636 |
+
raise HTTPException(status_code=500, detail=f"Lỗi scan sources: {str(e)}")
|
| 637 |
+
|
| 638 |
+
@app.get("/health")
|
| 639 |
+
async def health_check():
|
| 640 |
+
"""
|
| 641 |
+
Health check endpoint
|
| 642 |
+
"""
|
| 643 |
+
return {
|
| 644 |
+
"status": "healthy",
|
| 645 |
+
"message": "Video Generator API is running",
|
| 646 |
+
"timestamp": datetime.now().isoformat(),
|
| 647 |
+
"public_folder": PUBLIC_DIR,
|
| 648 |
+
"sources_count": len(source_matcher.sources_cache),
|
| 649 |
+
"active_jobs": len([j for j in job_manager.list_jobs() if j["status"] == JobStatus.PROCESSING])
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
# Cập nhật lại endpoint root để phản ánh thay đổi
|
| 653 |
+
@app.get("/")
|
| 654 |
+
async def root():
|
| 655 |
+
"""
|
| 656 |
+
Root endpoint với thông tin API
|
| 657 |
+
"""
|
| 658 |
+
return {
|
| 659 |
+
"message": "Video Generator API with Async Job Queue",
|
| 660 |
+
"version": "2.2.0", # Cập nhật phiên bản
|
| 661 |
+
"user": "vegasansaul23",
|
| 662 |
+
"features": [
|
| 663 |
+
"Async video generation",
|
| 664 |
+
"Job progress tracking",
|
| 665 |
+
"Background processing",
|
| 666 |
+
"Real-time status updates",
|
| 667 |
+
"Text-to-Audio conversion (via Hugging Face API)",
|
| 668 |
+
"Combined scene processing - gộp tất cả text và keywords"
|
| 669 |
+
],
|
| 670 |
+
"endpoints": {
|
| 671 |
+
"docs": "/docs",
|
| 672 |
+
"generate_video": "/generate-video",
|
| 673 |
+
"job_status": "/job-status/{job_id}",
|
| 674 |
+
"jobs": "/jobs",
|
| 675 |
+
"cancel_job": "/job/{job_id}",
|
| 676 |
+
"upload_audio": "/upload-audio",
|
| 677 |
+
"upload_source": "/upload-source",
|
| 678 |
+
"available_sources": "/available-sources",
|
| 679 |
+
"download": "/download-video/{filename}"
|
| 680 |
+
},
|
| 681 |
+
"processing_info": {
|
| 682 |
+
"text_processing": "Gộp tất cả text từ các scene thành 1 đoạn",
|
| 683 |
+
"keyword_processing": "Gộp và loại bỏ keywords trùng lặp",
|
| 684 |
+
"audio_generation": "Tạo audio một lần duy nhất từ text gộp"
|
| 685 |
+
},
|
| 686 |
+
"timestamp": datetime.now().isoformat()
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
if __name__ == "__main__":
|
| 690 |
+
import uvicorn
|
| 691 |
+
logger.info("Starting Video Generator API with Combined Scene Processing...")
|
| 692 |
+
logger.info(f"Public folder: {PUBLIC_DIR}")
|
| 693 |
+
logger.info(f"Output folder: {OUTPUT_DIR}")
|
| 694 |
+
logger.info(f"External Audio folder: {EXTERNAL_AUDIO_DIR}")
|
| 695 |
+
uvicorn.run(app, host="0.0.0.0", port=8000, timeout_keep_alive=30)
|
requirements.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
change_settings({"IMAGEMAGICK_BINARY": "/usr/bin/convert"})
|
source_matcher.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
from typing import List, Dict
|
| 4 |
+
import google.generativeai as genai
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import json
|
| 7 |
+
import asyncio
|
| 8 |
+
import random
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
class SourceMatcher:
|
| 13 |
+
def __init__(self, api_key: str = None, sources_root_dir: str = "public"):
|
| 14 |
+
"""
|
| 15 |
+
Khởi tạo SourceMatcher
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
api_key: Gemini API key (nếu không có sẽ lấy từ env)
|
| 19 |
+
sources_root_dir: Thư mục gốc chứa các source folder (mặc định: public)
|
| 20 |
+
"""
|
| 21 |
+
# Cấu hình Gemini AI
|
| 22 |
+
if api_key:
|
| 23 |
+
genai.configure(api_key=api_key)
|
| 24 |
+
else:
|
| 25 |
+
# Lấy từ environment variable
|
| 26 |
+
api_key = os.getenv('GEMINI_API_KEY')
|
| 27 |
+
if api_key:
|
| 28 |
+
genai.configure(api_key=api_key)
|
| 29 |
+
self.model = genai.GenerativeModel('gemini-pro')
|
| 30 |
+
self.use_ai = True
|
| 31 |
+
logger.info("Gemini AI được kích hoạt")
|
| 32 |
+
else:
|
| 33 |
+
logger.warning("GEMINI_API_KEY không được cung cấp, sử dụng keyword matching đơn giản")
|
| 34 |
+
self.model = None
|
| 35 |
+
self.use_ai = False
|
| 36 |
+
|
| 37 |
+
self.sources_root_dir = sources_root_dir
|
| 38 |
+
self.sources_cache = {}
|
| 39 |
+
|
| 40 |
+
# Tạo folder public nếu chưa có
|
| 41 |
+
os.makedirs(self.sources_root_dir, exist_ok=True)
|
| 42 |
+
|
| 43 |
+
# Scan available sources
|
| 44 |
+
self._scan_sources()
|
| 45 |
+
|
| 46 |
+
def _scan_sources(self):
|
| 47 |
+
"""
|
| 48 |
+
Scan tất cả các folder source có sẵn trong public
|
| 49 |
+
"""
|
| 50 |
+
try:
|
| 51 |
+
self.sources_cache = {}
|
| 52 |
+
|
| 53 |
+
if not os.path.exists(self.sources_root_dir):
|
| 54 |
+
logger.warning(f"Thư mục sources không tồn tại: {self.sources_root_dir}")
|
| 55 |
+
os.makedirs(self.sources_root_dir, exist_ok=True)
|
| 56 |
+
return
|
| 57 |
+
|
| 58 |
+
logger.info(f"Đang scan folder: {self.sources_root_dir}")
|
| 59 |
+
|
| 60 |
+
for item in os.listdir(self.sources_root_dir):
|
| 61 |
+
item_path = os.path.join(self.sources_root_dir, item)
|
| 62 |
+
|
| 63 |
+
if os.path.isdir(item_path):
|
| 64 |
+
# Tìm các file media trong folder
|
| 65 |
+
media_files = []
|
| 66 |
+
|
| 67 |
+
# Kiểm tra subfolder 'media'
|
| 68 |
+
media_folder = os.path.join(item_path, "media")
|
| 69 |
+
if os.path.exists(media_folder):
|
| 70 |
+
for file in os.listdir(media_folder):
|
| 71 |
+
file_path = os.path.join(media_folder, file)
|
| 72 |
+
if os.path.isfile(file_path) and self._is_media_file(file_path):
|
| 73 |
+
media_files.append(file_path)
|
| 74 |
+
|
| 75 |
+
# Tìm media files trực tiếp trong folder chính
|
| 76 |
+
for file in os.listdir(item_path):
|
| 77 |
+
file_path = os.path.join(item_path, file)
|
| 78 |
+
if os.path.isfile(file_path) and self._is_media_file(file_path):
|
| 79 |
+
media_files.append(file_path)
|
| 80 |
+
|
| 81 |
+
if media_files:
|
| 82 |
+
self.sources_cache[item] = {
|
| 83 |
+
"folder_name": item,
|
| 84 |
+
"folder_path": item_path,
|
| 85 |
+
"media_files": media_files,
|
| 86 |
+
"media_count": len(media_files)
|
| 87 |
+
}
|
| 88 |
+
logger.info(f"✓ Tìm thấy source: {item} ({len(media_files)} media files)")
|
| 89 |
+
else:
|
| 90 |
+
logger.info(f"✗ Folder trống hoặc không có media: {item}")
|
| 91 |
+
|
| 92 |
+
logger.info(f"Tổng cộng {len(self.sources_cache)} source folders có media")
|
| 93 |
+
|
| 94 |
+
# Log chi tiết các folder được tìm thấy
|
| 95 |
+
if self.sources_cache:
|
| 96 |
+
logger.info("Chi tiết sources:")
|
| 97 |
+
for folder_name, data in self.sources_cache.items():
|
| 98 |
+
logger.info(f" - {folder_name}: {data['media_count']} files")
|
| 99 |
+
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.error(f"Lỗi khi scan sources: {str(e)}")
|
| 102 |
+
|
| 103 |
+
def _is_media_file(self, file_path: str) -> bool:
|
| 104 |
+
"""
|
| 105 |
+
Kiểm tra xem file có phải là media không
|
| 106 |
+
"""
|
| 107 |
+
media_extensions = [
|
| 108 |
+
# Video formats
|
| 109 |
+
'.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv', '.wmv', '.m4v',
|
| 110 |
+
# Image formats
|
| 111 |
+
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif'
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
return any(file_path.lower().endswith(ext) for ext in media_extensions)
|
| 115 |
+
|
| 116 |
+
async def find_matching_sources(
|
| 117 |
+
self,
|
| 118 |
+
keywords: List[str],
|
| 119 |
+
title: str = "",
|
| 120 |
+
max_sources: int = 15
|
| 121 |
+
) -> List[str]:
|
| 122 |
+
"""
|
| 123 |
+
Tìm các source media phù hợp với keywords
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
keywords: Danh sách keywords
|
| 127 |
+
title: Tiêu đề video (optional)
|
| 128 |
+
max_sources: Số lượng source tối đa
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
Danh sách đường dẫn các file media phù hợp
|
| 132 |
+
"""
|
| 133 |
+
try:
|
| 134 |
+
if not self.sources_cache:
|
| 135 |
+
logger.warning("Không có source nào available trong folder public")
|
| 136 |
+
return []
|
| 137 |
+
|
| 138 |
+
logger.info(f"Tìm kiếm sources cho keywords: {keywords}")
|
| 139 |
+
logger.info(f"Title: {title}")
|
| 140 |
+
|
| 141 |
+
if self.use_ai and self.model:
|
| 142 |
+
# Sử dụng Gemini AI
|
| 143 |
+
selected_folders = await self._ai_matching(keywords, title, max_sources)
|
| 144 |
+
else:
|
| 145 |
+
# Sử dụng keyword matching đơn giản
|
| 146 |
+
selected_folders = self._simple_keyword_matching(keywords, title)
|
| 147 |
+
|
| 148 |
+
# Lấy media files từ các folder đã chọn
|
| 149 |
+
selected_media_files = []
|
| 150 |
+
|
| 151 |
+
for folder_info in selected_folders:
|
| 152 |
+
folder_name = folder_info.get('folder_name', '')
|
| 153 |
+
|
| 154 |
+
if folder_name in self.sources_cache:
|
| 155 |
+
folder_data = self.sources_cache[folder_name]
|
| 156 |
+
folder_files = folder_data['media_files']
|
| 157 |
+
|
| 158 |
+
# Shuffle để tạo sự ngẫu nhiên
|
| 159 |
+
random.shuffle(folder_files)
|
| 160 |
+
selected_media_files.extend(folder_files)
|
| 161 |
+
|
| 162 |
+
logger.info(
|
| 163 |
+
f"✓ Selected folder: {folder_name} "
|
| 164 |
+
f"(score: {folder_info.get('relevance_score', 'N/A'):.2f}) "
|
| 165 |
+
f"- {len(folder_files)} files"
|
| 166 |
+
)
|
| 167 |
+
else:
|
| 168 |
+
logger.warning(f"✗ Folder không tồn tại trong cache: {folder_name}")
|
| 169 |
+
|
| 170 |
+
# Shuffle toàn bộ danh sách để tạo sự đa dạng
|
| 171 |
+
random.shuffle(selected_media_files)
|
| 172 |
+
|
| 173 |
+
# Giới hạn số lượng files
|
| 174 |
+
if len(selected_media_files) > max_sources * 5:
|
| 175 |
+
selected_media_files = selected_media_files[:max_sources * 5]
|
| 176 |
+
|
| 177 |
+
logger.info(f"Tổng cộng {len(selected_media_files)} media files được chọn")
|
| 178 |
+
|
| 179 |
+
return selected_media_files
|
| 180 |
+
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.error(f"Lỗi khi tìm matching sources: {str(e)}")
|
| 183 |
+
return self.get_fallback_sources()
|
| 184 |
+
|
| 185 |
+
async def _ai_matching(self, keywords: List[str], title: str, max_sources: int) -> List[Dict]:
|
| 186 |
+
"""
|
| 187 |
+
Sử dụng Gemini AI để matching
|
| 188 |
+
"""
|
| 189 |
+
try:
|
| 190 |
+
source_names = list(self.sources_cache.keys())
|
| 191 |
+
|
| 192 |
+
prompt = f"""
|
| 193 |
+
Bạn là một AI chuyên phân tích và matching nội dung video cho người Việt Nam.
|
| 194 |
+
|
| 195 |
+
NHIỆM VỤ: Tìm các folder source phù hợp nhất với keywords và title đã cho.
|
| 196 |
+
|
| 197 |
+
THÔNG TIN INPUT:
|
| 198 |
+
- Title: "{title}"
|
| 199 |
+
- Keywords: {keywords}
|
| 200 |
+
|
| 201 |
+
CÁC FOLDER SOURCE AVAILABLE trong thư mục public:
|
| 202 |
+
{json.dumps(source_names, ensure_ascii=False, indent=2)}
|
| 203 |
+
|
| 204 |
+
YÊU CẦU:
|
| 205 |
+
1. Phân tích semantic meaning của title và keywords (hỗ trợ tiếng Việt)
|
| 206 |
+
2. So sánh với tên các folder source
|
| 207 |
+
3. Chọn tối đa {max_sources} folder phù hợp nhất
|
| 208 |
+
4. Sắp xếp theo độ phù hợp (cao nhất trước)
|
| 209 |
+
5. Ưu tiên folder có tên chứa keywords hoặc có ý nghĩa tương tự
|
| 210 |
+
6. Xem xét cả từ khóa tiếng Việt và tiếng Anh
|
| 211 |
+
|
| 212 |
+
Trả về CHÍNH XÁC theo format JSON sau:
|
| 213 |
+
|
| 214 |
+
{{
|
| 215 |
+
"analysis": "Phân tích ngắn gọn về sự phù hợp",
|
| 216 |
+
"selected_folders": [
|
| 217 |
+
{{
|
| 218 |
+
"folder_name": "tên_folder_chính_xác_từ_danh_sách",
|
| 219 |
+
"relevance_score": 0.95,
|
| 220 |
+
"reason": "Lý do chọn folder này"
|
| 221 |
+
}}
|
| 222 |
+
]
|
| 223 |
+
}}
|
| 224 |
+
|
| 225 |
+
LưU Ý:
|
| 226 |
+
- CHỈ chọn folder names CÓ TRONG danh sách available ở trên
|
| 227 |
+
- Relevance score từ 0.0 đến 1.0
|
| 228 |
+
- Không tạo ra tên folder mới
|
| 229 |
+
- Ưu tiên folders liên quan đến chủ đề
|
| 230 |
+
"""
|
| 231 |
+
|
| 232 |
+
logger.info("Đang phân tích sources với Gemini AI...")
|
| 233 |
+
|
| 234 |
+
# Gọi Gemini API
|
| 235 |
+
response = self.model.generate_content(prompt)
|
| 236 |
+
response_text = response.text.strip()
|
| 237 |
+
|
| 238 |
+
logger.info(f"Gemini response: {response_text[:200]}...")
|
| 239 |
+
|
| 240 |
+
# Parse JSON response
|
| 241 |
+
try:
|
| 242 |
+
# Tìm và extract JSON từ response
|
| 243 |
+
start_idx = response_text.find('{')
|
| 244 |
+
end_idx = response_text.rfind('}') + 1
|
| 245 |
+
|
| 246 |
+
if start_idx >= 0 and end_idx > start_idx:
|
| 247 |
+
json_str = response_text[start_idx:end_idx]
|
| 248 |
+
result = json.loads(json_str)
|
| 249 |
+
selected_folders = result.get('selected_folders', [])
|
| 250 |
+
|
| 251 |
+
logger.info(f"Gemini analysis: {result.get('analysis', 'No analysis')}")
|
| 252 |
+
return selected_folders
|
| 253 |
+
else:
|
| 254 |
+
raise ValueError("Không tìm thấy JSON trong response")
|
| 255 |
+
|
| 256 |
+
except (json.JSONDecodeError, ValueError) as e:
|
| 257 |
+
logger.error(f"Lỗi parse JSON từ Gemini: {e}")
|
| 258 |
+
logger.info("Fallback to simple keyword matching")
|
| 259 |
+
return self._simple_keyword_matching(keywords, title)
|
| 260 |
+
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.error(f"Lỗi Gemini AI: {str(e)}")
|
| 263 |
+
return self._simple_keyword_matching(keywords, title)
|
| 264 |
+
|
| 265 |
+
def _simple_keyword_matching(self, keywords: List[str], title: str) -> List[Dict]:
|
| 266 |
+
"""
|
| 267 |
+
Fallback method: keyword matching đơn giản
|
| 268 |
+
"""
|
| 269 |
+
logger.info("Sử dụng simple keyword matching...")
|
| 270 |
+
|
| 271 |
+
all_text_lower = " ".join(keywords + [title]).lower()
|
| 272 |
+
matches = []
|
| 273 |
+
|
| 274 |
+
for folder_name, folder_data in self.sources_cache.items():
|
| 275 |
+
folder_name_lower = folder_name.lower()
|
| 276 |
+
|
| 277 |
+
# Tính score dựa trên số keywords match
|
| 278 |
+
score = 0
|
| 279 |
+
match_reasons = []
|
| 280 |
+
|
| 281 |
+
# Kiểm tra từng keyword
|
| 282 |
+
for keyword in keywords:
|
| 283 |
+
keyword_lower = keyword.lower()
|
| 284 |
+
if keyword_lower in folder_name_lower:
|
| 285 |
+
score += 0.4
|
| 286 |
+
match_reasons.append(f"keyword '{keyword}'")
|
| 287 |
+
|
| 288 |
+
# Kiểm tra title
|
| 289 |
+
if title and title.lower() in folder_name_lower:
|
| 290 |
+
score += 0.3
|
| 291 |
+
match_reasons.append("title")
|
| 292 |
+
|
| 293 |
+
# Kiểm tra các từ phổ biến
|
| 294 |
+
common_terms = {
|
| 295 |
+
'sport': ['sport', 'thể thao', 'bóng đá', 'football'],
|
| 296 |
+
'news': ['news', 'tin tức', 'báo'],
|
| 297 |
+
'u23': ['u23', 'đội tuyển'],
|
| 298 |
+
'vietnam': ['vietnam', 'việt nam', 'vn'],
|
| 299 |
+
'music': ['music', 'nhạc', 'âm nhạc']
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
for category, terms in common_terms.items():
|
| 303 |
+
for term in terms:
|
| 304 |
+
if term in all_text_lower and term in folder_name_lower:
|
| 305 |
+
score += 0.2
|
| 306 |
+
match_reasons.append(f"{category} term")
|
| 307 |
+
break
|
| 308 |
+
|
| 309 |
+
if score > 0:
|
| 310 |
+
matches.append({
|
| 311 |
+
"folder_name": folder_name,
|
| 312 |
+
"relevance_score": min(score, 1.0),
|
| 313 |
+
"reason": f"Keyword matching: {', '.join(match_reasons)}"
|
| 314 |
+
})
|
| 315 |
+
|
| 316 |
+
# Sắp xếp theo score
|
| 317 |
+
matches.sort(key=lambda x: x['relevance_score'], reverse=True)
|
| 318 |
+
|
| 319 |
+
logger.info(f"Simple matching found {len(matches)} relevant folders")
|
| 320 |
+
return matches[:10] # Top 10
|
| 321 |
+
|
| 322 |
+
def get_fallback_sources(self, max_files: int = 20) -> List[str]:
|
| 323 |
+
"""
|
| 324 |
+
Lấy một số source ngẫu nhiên làm fallback
|
| 325 |
+
"""
|
| 326 |
+
logger.info("Sử dụng fallback sources...")
|
| 327 |
+
|
| 328 |
+
all_files = []
|
| 329 |
+
for folder_data in self.sources_cache.values():
|
| 330 |
+
all_files.extend(folder_data['media_files'])
|
| 331 |
+
|
| 332 |
+
if all_files:
|
| 333 |
+
random.shuffle(all_files)
|
| 334 |
+
selected = all_files[:max_files]
|
| 335 |
+
logger.info(f"Fallback: chọn {len(selected)} files ngẫu nhiên")
|
| 336 |
+
return selected
|
| 337 |
+
|
| 338 |
+
return []
|
| 339 |
+
|
| 340 |
+
async def get_available_sources(self) -> Dict:
|
| 341 |
+
"""
|
| 342 |
+
Lấy danh sách tất cả sources available
|
| 343 |
+
"""
|
| 344 |
+
self._scan_sources() # Refresh cache
|
| 345 |
+
|
| 346 |
+
return {
|
| 347 |
+
"total_folders": len(self.sources_cache),
|
| 348 |
+
"folders": [
|
| 349 |
+
{
|
| 350 |
+
"name": folder_name,
|
| 351 |
+
"path": data["folder_path"],
|
| 352 |
+
"media_count": data["media_count"],
|
| 353 |
+
"sample_files": data["media_files"][:3] # 3 files đầu làm sample
|
| 354 |
+
}
|
| 355 |
+
for folder_name, data in self.sources_cache.items()
|
| 356 |
+
]
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
def refresh_sources(self):
|
| 360 |
+
"""
|
| 361 |
+
Refresh danh sách sources
|
| 362 |
+
"""
|
| 363 |
+
logger.info("Refreshing sources cache...")
|
| 364 |
+
self._scan_sources()
|
| 365 |
+
return len(self.sources_cache)
|
video_generator_module.py
ADDED
|
@@ -0,0 +1,1040 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import random
|
| 4 |
+
from moviepy.editor import (
|
| 5 |
+
AudioFileClip, ImageClip, concatenate_videoclips,
|
| 6 |
+
VideoFileClip, ColorClip, CompositeVideoClip, TextClip
|
| 7 |
+
)
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import math
|
| 10 |
+
# Thêm đoạn này vào đầu file video_generator_module.py
|
| 11 |
+
import PIL.Image
|
| 12 |
+
import os
|
| 13 |
+
from moviepy.config import change_settings
|
| 14 |
+
|
| 15 |
+
# Use system ImageMagick in Docker
|
| 16 |
+
change_settings({"IMAGEMAGICK_BINARY": "/usr/bin/convert"})
|
| 17 |
+
|
| 18 |
+
# Patch thuộc tính ANTIALIAS nếu thiếu
|
| 19 |
+
if not hasattr(PIL.Image, 'ANTIALIAS'):
|
| 20 |
+
PIL.Image.ANTIALIAS = PIL.Image.Resampling.LANCZOS
|
| 21 |
+
|
| 22 |
+
# --- CẤU HÌNH LOGGING ---
|
| 23 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 24 |
+
|
| 25 |
+
# --- HẰNG SỐ VÀ THIẾT LẬP CHUNG ---
|
| 26 |
+
DEFAULT_SAVE_DIR = "generated_video_output"
|
| 27 |
+
DEFAULT_ASPECT_RATIOS = {
|
| 28 |
+
"ngang": (16, 9), # Phổ biến cho YouTube, Facebook feed
|
| 29 |
+
"doc": (9, 16), # Phổ biến cho TikTok, Reels, Shorts
|
| 30 |
+
"vuong": (1, 1) # Phổ biến cho Instagram Feed, Twitter
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
# Cấu hình cho thumbnail overlay
|
| 34 |
+
THUMBNAIL_SHOW_DURATION = 5.0 # 5 giây đầu
|
| 35 |
+
THUMBNAIL_HEIGHT_RATIO = 1/4 # 1/3 chiều cao video (có thể bị điều chỉnh)
|
| 36 |
+
LOGO_SIZE_RATIO = 0.16 # Logo chiếm 8% chiều rộng video
|
| 37 |
+
LOGO_MARGIN = 20 # Margin từ góc
|
| 38 |
+
TEXT_MARGIN = 20 # Margin cho text trong thumbnail
|
| 39 |
+
|
| 40 |
+
# Cấu hình animation - TỐC ĐỘ CHẬM HƠN
|
| 41 |
+
ZOOM_ANIMATION_DURATION = 4.0 # Tăng lên 4 giây cho animation chậm hơn
|
| 42 |
+
TRANSITION_DURATION = 0.5 # Thời gian chuyển cảnh
|
| 43 |
+
BOTTOM_PADDING = 160 # Padding dưới cùng (px)
|
| 44 |
+
|
| 45 |
+
# --- HỖ TRỢ CHUYỂN ĐỔI TỶ LỆ KHUNG HÌNH ---
|
| 46 |
+
def get_resolution(aspect_ratio_key: str, height: int = 1080) -> tuple[int, int]:
|
| 47 |
+
"""
|
| 48 |
+
Trả về độ phân giải (width, height) dựa trên key tỷ lệ khung hình và chiều cao mong muốn.
|
| 49 |
+
"""
|
| 50 |
+
if aspect_ratio_key not in DEFAULT_ASPECT_RATIOS:
|
| 51 |
+
raise ValueError(f"Tỷ lệ khung hình '{aspect_ratio_key}' không hỗ trợ. Hãy chọn trong: {list(DEFAULT_ASPECT_RATIOS.keys())}")
|
| 52 |
+
|
| 53 |
+
width_ratio, height_ratio = DEFAULT_ASPECT_RATIOS[aspect_ratio_key]
|
| 54 |
+
|
| 55 |
+
if height_ratio == 0:
|
| 56 |
+
raise ValueError("Tỷ lệ chiều cao không được bằng 0.")
|
| 57 |
+
|
| 58 |
+
width = int(height * width_ratio / height_ratio)
|
| 59 |
+
width = width + (width % 2) # Đảm bảo kích thước là số chẵn
|
| 60 |
+
|
| 61 |
+
logging.info(f"Độ phân giải cho '{aspect_ratio_key}' (height={height}): ({width}, {height})")
|
| 62 |
+
return (width, height)
|
| 63 |
+
|
| 64 |
+
def get_media_dimensions(clip):
|
| 65 |
+
"""
|
| 66 |
+
Lấy kích thước thực tế của media clip
|
| 67 |
+
"""
|
| 68 |
+
if hasattr(clip, 'w') and hasattr(clip, 'h'):
|
| 69 |
+
return clip.w, clip.h
|
| 70 |
+
elif hasattr(clip, 'size'):
|
| 71 |
+
return clip.size
|
| 72 |
+
else:
|
| 73 |
+
return None, None
|
| 74 |
+
|
| 75 |
+
def calculate_aspect_ratio(width, height):
|
| 76 |
+
"""
|
| 77 |
+
Tính tỷ lệ khung hình của media
|
| 78 |
+
"""
|
| 79 |
+
if width and height and height != 0:
|
| 80 |
+
return width / height
|
| 81 |
+
return None
|
| 82 |
+
|
| 83 |
+
def create_zoom_animation(clip, target_resolution, duration, zoom_type='in_out'):
|
| 84 |
+
"""
|
| 85 |
+
Tạo hiệu ứng zoom in/out liên tục cho ảnh với FULL FILL tuyệt đối.
|
| 86 |
+
Sử dụng resize thông minh thay vì crop để tránh mất width.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
clip: ImageClip cần tạo animation
|
| 90 |
+
target_resolution: (width, height) của video đích
|
| 91 |
+
duration: Thời gian hiển thị ảnh
|
| 92 |
+
zoom_type: 'in_out', 'in', 'out', 'random'
|
| 93 |
+
|
| 94 |
+
Returns:
|
| 95 |
+
VideoClip với hiệu ứng zoom fill tuyệt đối, không mất width
|
| 96 |
+
"""
|
| 97 |
+
target_width, target_height = target_resolution
|
| 98 |
+
effective_height = target_height - BOTTOM_PADDING # Chiều cao hiệu dụng (trừ padding dưới)
|
| 99 |
+
|
| 100 |
+
# Lấy kích thước gốc
|
| 101 |
+
original_width, original_height = get_media_dimensions(clip)
|
| 102 |
+
if not original_width or not original_height:
|
| 103 |
+
return clip.resize(target_resolution).set_duration(duration)
|
| 104 |
+
|
| 105 |
+
logging.info(f"Zoom Animation - Original: {original_width}x{original_height}")
|
| 106 |
+
logging.info(f"Zoom Animation - Target fill area: {target_width}x{effective_height}")
|
| 107 |
+
|
| 108 |
+
# Tính scale để FILL HOÀN TOÀN vùng hiệu dụng (sẽ crop phần thừa)
|
| 109 |
+
scale_by_width = target_width / original_width
|
| 110 |
+
scale_by_height = effective_height / original_height
|
| 111 |
+
|
| 112 |
+
# Chọn scale LỚN HƠN để đảm bảo fill hoàn toàn không có khoảng trống
|
| 113 |
+
base_scale_to_fill = max(scale_by_width, scale_by_height)
|
| 114 |
+
|
| 115 |
+
# Zoom range nhỏ để animation mượt và không bị crop quá nhiều
|
| 116 |
+
zoom_range = 0.08 # Giảm xuống 8% để đảm bảo luôn fill và không mất content
|
| 117 |
+
|
| 118 |
+
if zoom_type == 'random':
|
| 119 |
+
zoom_type = random.choice(['in_out', 'in', 'out'])
|
| 120 |
+
|
| 121 |
+
logging.info(f"Zoom Animation - Base scale to fill: {base_scale_to_fill:.3f}")
|
| 122 |
+
logging.info(f"Zoom Animation - Zoom range: {zoom_range:.2f}")
|
| 123 |
+
logging.info(f"Zoom Animation - Type: {zoom_type}, Duration: {duration:.1f}s")
|
| 124 |
+
|
| 125 |
+
def get_zoom_factor(t):
|
| 126 |
+
"""Tính zoom factor đảm bảo luôn >= base_scale_to_fill"""
|
| 127 |
+
progress = t / duration
|
| 128 |
+
|
| 129 |
+
if zoom_type == 'in_out':
|
| 130 |
+
# Zoom in/out với sin wave, từ base_scale lên base_scale * (1 + zoom_range)
|
| 131 |
+
factor = base_scale_to_fill * (1 + zoom_range * (0.5 + 0.5 * math.sin(progress * math.pi * 2)))
|
| 132 |
+
elif zoom_type == 'in':
|
| 133 |
+
# Zoom in từ base_scale * (1 + zoom_range) về base_scale
|
| 134 |
+
factor = base_scale_to_fill * (1 + zoom_range * (1 - progress ** 0.8))
|
| 135 |
+
elif zoom_type == 'out':
|
| 136 |
+
# Zoom out từ base_scale lên base_scale * (1 + zoom_range)
|
| 137 |
+
factor = base_scale_to_fill * (1 + zoom_range * (progress ** 0.8))
|
| 138 |
+
else:
|
| 139 |
+
factor = base_scale_to_fill
|
| 140 |
+
|
| 141 |
+
return factor
|
| 142 |
+
|
| 143 |
+
def make_frame_at_time(t):
|
| 144 |
+
"""Tạo frame tại thời điểm t - SỬ DỤNG RESIZE THÔNG MINH thay vì crop"""
|
| 145 |
+
current_scale = get_zoom_factor(t)
|
| 146 |
+
|
| 147 |
+
# Tính kích thước sau scale để fill vùng effective
|
| 148 |
+
scaled_width = int(original_width * current_scale)
|
| 149 |
+
scaled_height = int(original_height * current_scale)
|
| 150 |
+
|
| 151 |
+
# Đảm bảo kích thước chẵn
|
| 152 |
+
scaled_width = scaled_width + (scaled_width % 2)
|
| 153 |
+
scaled_height = scaled_height + (scaled_height % 2)
|
| 154 |
+
|
| 155 |
+
logging.debug(f"Frame at t={t:.2f}: scale={current_scale:.3f}, size={scaled_width}x{scaled_height}")
|
| 156 |
+
|
| 157 |
+
# CÁCH MỚI: Resize trực tiếp về target size thay vì crop
|
| 158 |
+
# Điều này sẽ giữ toàn bộ content và tỷ lệ
|
| 159 |
+
|
| 160 |
+
# Tạo clip tạm với kích thước đã scale
|
| 161 |
+
temp_resized = clip.resize((scaled_width, scaled_height))
|
| 162 |
+
|
| 163 |
+
# Kiểm tra xem scaled size có cover đủ target area không
|
| 164 |
+
if scaled_width >= target_width and scaled_height >= effective_height:
|
| 165 |
+
# Nếu đủ lớn, crop từ center để lấy đúng target size
|
| 166 |
+
crop_x_start = (scaled_width - target_width) // 2
|
| 167 |
+
crop_y_start = (scaled_height - effective_height) // 2
|
| 168 |
+
|
| 169 |
+
final_clip = temp_resized.crop(
|
| 170 |
+
x1=crop_x_start,
|
| 171 |
+
y1=crop_y_start,
|
| 172 |
+
x2=crop_x_start + target_width,
|
| 173 |
+
y2=crop_y_start + effective_height
|
| 174 |
+
)
|
| 175 |
+
else:
|
| 176 |
+
# Nếu không đủ lớn (trường hợp này không nên xảy ra với scale > base_scale_to_fill)
|
| 177 |
+
# Resize force về target size
|
| 178 |
+
final_clip = temp_resized.resize((target_width, effective_height))
|
| 179 |
+
|
| 180 |
+
# Lấy frame từ ảnh (t=0 vì là static image)
|
| 181 |
+
return final_clip.get_frame(0)
|
| 182 |
+
|
| 183 |
+
# Tạo video clip với animation function
|
| 184 |
+
animated_clip = clip.set_duration(duration).fl(
|
| 185 |
+
lambda get_frame, t: make_frame_at_time(t),
|
| 186 |
+
apply_to=[]
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# Tạo background đen cho toàn bộ màn hình
|
| 190 |
+
background = ColorClip(
|
| 191 |
+
size=target_resolution,
|
| 192 |
+
color=(0, 0, 0),
|
| 193 |
+
duration=duration
|
| 194 |
+
).set_fps(24)
|
| 195 |
+
|
| 196 |
+
# Composite: background + animated image ở vị trí top-center
|
| 197 |
+
final_composite = CompositeVideoClip([
|
| 198 |
+
background,
|
| 199 |
+
animated_clip.set_position(("center", "top"))
|
| 200 |
+
], size=target_resolution)
|
| 201 |
+
|
| 202 |
+
logging.info(f"Zoom Animation - Hoàn thành với fill tuyệt đối, giữ nguyên width")
|
| 203 |
+
|
| 204 |
+
return final_composite
|
| 205 |
+
|
| 206 |
+
def create_transition_effect(clip1, clip2, transition_type='fade', duration=TRANSITION_DURATION):
|
| 207 |
+
"""
|
| 208 |
+
Tạo hiệu ứng chuyển cảnh giữa hai clip.
|
| 209 |
+
"""
|
| 210 |
+
if not clip1 or not clip2:
|
| 211 |
+
return [clip1, clip2] if clip1 and clip2 else ([clip1] if clip1 else [clip2])
|
| 212 |
+
|
| 213 |
+
# Đảm bảo clips có cùng kích thước
|
| 214 |
+
if hasattr(clip1, 'size') and hasattr(clip2, 'size'):
|
| 215 |
+
target_size = clip1.size
|
| 216 |
+
if clip2.size != target_size:
|
| 217 |
+
clip2 = clip2.resize(target_size)
|
| 218 |
+
|
| 219 |
+
if transition_type == 'fade':
|
| 220 |
+
# Fade transition
|
| 221 |
+
clip1_fade_out = clip1.fadeout(duration)
|
| 222 |
+
clip2_fade_in = clip2.fadein(duration)
|
| 223 |
+
|
| 224 |
+
# Tạo overlap
|
| 225 |
+
clip1_trimmed = clip1_fade_out.subclip(0, clip1.duration - duration/2)
|
| 226 |
+
clip2_delayed = clip2_fade_in.set_start(clip1.duration - duration/2)
|
| 227 |
+
|
| 228 |
+
return [clip1_trimmed, clip2_delayed]
|
| 229 |
+
|
| 230 |
+
elif transition_type in ['slide_left', 'slide_right', 'slide_up', 'slide_down']:
|
| 231 |
+
# Slide transitions
|
| 232 |
+
target_size = clip1.size if hasattr(clip1, 'size') else (1080, 1920)
|
| 233 |
+
|
| 234 |
+
# Xác định hướng di chuyển
|
| 235 |
+
if transition_type == 'slide_left':
|
| 236 |
+
start_pos = (target_size[0], 0)
|
| 237 |
+
end_pos = (0, 0)
|
| 238 |
+
elif transition_type == 'slide_right':
|
| 239 |
+
start_pos = (-target_size[0], 0)
|
| 240 |
+
end_pos = (0, 0)
|
| 241 |
+
elif transition_type == 'slide_up':
|
| 242 |
+
start_pos = (0, target_size[1])
|
| 243 |
+
end_pos = (0, 0)
|
| 244 |
+
else: # slide_down
|
| 245 |
+
start_pos = (0, -target_size[1])
|
| 246 |
+
end_pos = (0, 0)
|
| 247 |
+
|
| 248 |
+
# Tạo animation cho clip2
|
| 249 |
+
clip2_animated = (clip2
|
| 250 |
+
.set_position(lambda t: (
|
| 251 |
+
start_pos[0] + (end_pos[0] - start_pos[0]) * min(t/duration, 1),
|
| 252 |
+
start_pos[1] + (end_pos[1] - start_pos[1]) * min(t/duration, 1)
|
| 253 |
+
))
|
| 254 |
+
.set_start(clip1.duration - duration))
|
| 255 |
+
|
| 256 |
+
clip1_trimmed = clip1.subclip(0, clip1.duration - duration/2)
|
| 257 |
+
|
| 258 |
+
return [clip1_trimmed, clip2_animated]
|
| 259 |
+
|
| 260 |
+
elif transition_type == 'zoom':
|
| 261 |
+
# Zoom transition
|
| 262 |
+
clip1_zoom_out = clip1.resize(lambda t: 1 - 0.5 * min(t/(clip1.duration), 1)).fadeout(duration)
|
| 263 |
+
clip2_zoom_in = clip2.resize(lambda t: 0.5 + 0.5 * min(t/duration, 1)).fadein(duration)
|
| 264 |
+
|
| 265 |
+
clip1_trimmed = clip1_zoom_out.subclip(0, clip1.duration - duration/2)
|
| 266 |
+
clip2_delayed = clip2_zoom_in.set_start(clip1.duration - duration/2)
|
| 267 |
+
|
| 268 |
+
return [clip1_trimmed, clip2_delayed]
|
| 269 |
+
|
| 270 |
+
else:
|
| 271 |
+
# No transition
|
| 272 |
+
return [clip1, clip2]
|
| 273 |
+
|
| 274 |
+
def smart_fit_and_fill(clip, target_resolution, media_type='image', fill_color=(0, 0, 0)):
|
| 275 |
+
"""
|
| 276 |
+
Fit media vào target resolution mà không méo hình, và fill các vùng trống với màu nền.
|
| 277 |
+
"""
|
| 278 |
+
target_width, target_height = target_resolution
|
| 279 |
+
|
| 280 |
+
# Lấy kích thước gốc của clip
|
| 281 |
+
original_width, original_height = get_media_dimensions(clip)
|
| 282 |
+
|
| 283 |
+
if not original_width or not original_height:
|
| 284 |
+
logging.warning(f"Không thể lấy kích thước gốc của clip, sử dụng resize thông thường")
|
| 285 |
+
return clip.resize(target_resolution)
|
| 286 |
+
|
| 287 |
+
original_aspect_ratio = calculate_aspect_ratio(original_width, original_height)
|
| 288 |
+
target_aspect_ratio = target_width / target_height
|
| 289 |
+
|
| 290 |
+
if not original_aspect_ratio:
|
| 291 |
+
logging.warning(f"Không thể tính tỷ lệ khung hình gốc, sử dụng resize thông thường")
|
| 292 |
+
return clip.resize(target_resolution)
|
| 293 |
+
|
| 294 |
+
logging.info(f"Media gốc: {original_width}x{original_height} (ratio: {original_aspect_ratio:.2f})")
|
| 295 |
+
logging.info(f"Target: {target_width}x{target_height} (ratio: {target_aspect_ratio:.2f})")
|
| 296 |
+
|
| 297 |
+
# Kiểm tra nếu tỷ lệ gần như giống nhau
|
| 298 |
+
if abs(original_aspect_ratio - target_aspect_ratio) < 0.01:
|
| 299 |
+
logging.info("Tỷ lệ khung hình gốc phù hợp, chỉ cần resize")
|
| 300 |
+
return clip.resize(target_resolution)
|
| 301 |
+
|
| 302 |
+
# Tính toán scale để fit (không bị méo)
|
| 303 |
+
scale_by_width = target_width / original_width
|
| 304 |
+
scale_by_height = target_height / original_height
|
| 305 |
+
|
| 306 |
+
# Chọn scale nhỏ hơn để đảm bảo fit hoàn toàn vào target
|
| 307 |
+
scale_factor = min(scale_by_width, scale_by_height)
|
| 308 |
+
|
| 309 |
+
# Tính kích thước mới sau khi scale
|
| 310 |
+
new_width = int(original_width * scale_factor)
|
| 311 |
+
new_height = int(original_height * scale_factor)
|
| 312 |
+
|
| 313 |
+
# Đảm bảo kích thước chẵn
|
| 314 |
+
new_width = new_width + (new_width % 2)
|
| 315 |
+
new_height = new_height + (new_height % 2)
|
| 316 |
+
|
| 317 |
+
logging.info(f"Scale factor: {scale_factor:.3f}")
|
| 318 |
+
logging.info(f"Media sau khi fit: {new_width}x{new_height}")
|
| 319 |
+
|
| 320 |
+
# Resize clip với tỷ lệ đã tính
|
| 321 |
+
fitted_clip = clip.resize((new_width, new_height))
|
| 322 |
+
|
| 323 |
+
# Tính vị trí để center media trong target frame
|
| 324 |
+
x_offset = (target_width - new_width) // 2
|
| 325 |
+
y_offset = (target_height - new_height) // 2
|
| 326 |
+
|
| 327 |
+
logging.info(f"Vị trí center: ({x_offset}, {y_offset})")
|
| 328 |
+
|
| 329 |
+
# Tạo background với màu fill
|
| 330 |
+
duration = getattr(clip, 'duration', 1.0) if hasattr(clip, 'duration') else 1.0
|
| 331 |
+
background = ColorClip(
|
| 332 |
+
size=target_resolution,
|
| 333 |
+
color=fill_color,
|
| 334 |
+
duration=duration
|
| 335 |
+
).set_fps(24)
|
| 336 |
+
|
| 337 |
+
# Đặt vị trí cho fitted clip
|
| 338 |
+
fitted_clip = fitted_clip.set_position((x_offset, y_offset))
|
| 339 |
+
|
| 340 |
+
# Composite background và fitted clip
|
| 341 |
+
final_clip = CompositeVideoClip([background, fitted_clip], size=target_resolution)
|
| 342 |
+
|
| 343 |
+
return final_clip
|
| 344 |
+
|
| 345 |
+
def smart_crop_to_fill(clip, target_resolution, media_type='image', fill_color=(255, 255, 255), bottom_padding: int = 160):
|
| 346 |
+
"""
|
| 347 |
+
Crop phần trung tâm của clip sao cho vừa với target resolution mà không bị méo hình.
|
| 348 |
+
Phía dưới sẽ chừa ra một vùng trống (màu đen hoặc màu chỉ định) để hiển thị caption, logo,...
|
| 349 |
+
"""
|
| 350 |
+
target_width, target_height = target_resolution
|
| 351 |
+
effective_crop_height = target_height - bottom_padding # phần sẽ chứa nội dung
|
| 352 |
+
|
| 353 |
+
original_width, original_height = get_media_dimensions(clip)
|
| 354 |
+
|
| 355 |
+
if not original_width or not original_height:
|
| 356 |
+
return clip.resize(target_resolution)
|
| 357 |
+
|
| 358 |
+
target_ratio = target_width / effective_crop_height
|
| 359 |
+
original_ratio = original_width / original_height
|
| 360 |
+
|
| 361 |
+
# Crop giữ phần trung tâm
|
| 362 |
+
if original_ratio > target_ratio:
|
| 363 |
+
new_width = int(original_height * target_ratio)
|
| 364 |
+
x1 = (original_width - new_width) // 2
|
| 365 |
+
x2 = x1 + new_width
|
| 366 |
+
y1 = 0
|
| 367 |
+
y2 = original_height
|
| 368 |
+
else:
|
| 369 |
+
new_height = int(original_width / target_ratio)
|
| 370 |
+
y1 = (original_height - new_height) // 2
|
| 371 |
+
y2 = y1 + new_height
|
| 372 |
+
x1 = 0
|
| 373 |
+
x2 = original_width
|
| 374 |
+
|
| 375 |
+
cropped = clip.crop(x1=x1, y1=y1, x2=x2, y2=y2)
|
| 376 |
+
resized_cropped = cropped.resize((target_width, effective_crop_height))
|
| 377 |
+
|
| 378 |
+
# Tạo background đen (hoặc màu fill)
|
| 379 |
+
duration = getattr(clip, 'duration', 1.0)
|
| 380 |
+
background = ColorClip(size=target_resolution, color=fill_color, duration=duration).set_fps(24)
|
| 381 |
+
|
| 382 |
+
# Đặt phần crop ở top-center
|
| 383 |
+
final = CompositeVideoClip([background, resized_cropped.set_position(("center", "top"))], size=target_resolution)
|
| 384 |
+
|
| 385 |
+
return final
|
| 386 |
+
|
| 387 |
+
# --- CÁC HÀM MỚI CHO THUMBNAIL OVERLAY ---
|
| 388 |
+
|
| 389 |
+
def create_thumbnail_overlay(
|
| 390 |
+
thumbnail_path: str,
|
| 391 |
+
logo_path: str,
|
| 392 |
+
description: str,
|
| 393 |
+
date: str,
|
| 394 |
+
target_resolution: tuple[int, int],
|
| 395 |
+
duration: float = THUMBNAIL_SHOW_DURATION
|
| 396 |
+
) -> CompositeVideoClip:
|
| 397 |
+
"""
|
| 398 |
+
Tạo overlay thumbnail với logo và text cho 5 giây đầu video.
|
| 399 |
+
Thumbnail sẽ được scale để CHIỀU RỘNG luôn bao phủ đủ (có thể hi sinh chiều cao).
|
| 400 |
+
"""
|
| 401 |
+
description = description.upper()
|
| 402 |
+
target_width, target_height = target_resolution
|
| 403 |
+
max_thumbnail_height = int(target_height * THUMBNAIL_HEIGHT_RATIO)
|
| 404 |
+
|
| 405 |
+
# 1. Tạo nền trong suốt cho overlay
|
| 406 |
+
overlay_background = ColorClip(
|
| 407 |
+
size=target_resolution,
|
| 408 |
+
color=(0, 0, 0), # Đen
|
| 409 |
+
duration=duration
|
| 410 |
+
).set_opacity(0.3) # Trong suốt
|
| 411 |
+
|
| 412 |
+
overlay_clips = [overlay_background]
|
| 413 |
+
|
| 414 |
+
# 2. Xử lý thumbnail - SCALE ĐỂ FILL WIDTH
|
| 415 |
+
actual_thumbnail_height = max_thumbnail_height # Mặc định
|
| 416 |
+
thumbnail_y_position = target_height - max_thumbnail_height
|
| 417 |
+
|
| 418 |
+
if thumbnail_path and os.path.exists(thumbnail_path):
|
| 419 |
+
try:
|
| 420 |
+
thumbnail_clip = ImageClip(thumbnail_path, duration=duration)
|
| 421 |
+
|
| 422 |
+
# Lấy kích thước gốc của thumbnail
|
| 423 |
+
original_thumb_width, original_thumb_height = get_media_dimensions(thumbnail_clip)
|
| 424 |
+
|
| 425 |
+
if original_thumb_width and original_thumb_height:
|
| 426 |
+
# SCALE ĐỂ FILL WIDTH (ưu tiên chiều rộng)
|
| 427 |
+
scale_to_fit_width = target_width / original_thumb_width
|
| 428 |
+
|
| 429 |
+
# Tính chiều cao sau khi scale theo width
|
| 430 |
+
scaled_height = int(original_thumb_height * scale_to_fit_width)
|
| 431 |
+
|
| 432 |
+
# Resize thumbnail để fill width
|
| 433 |
+
thumbnail_resized = thumbnail_clip.resize((target_width, scaled_height))
|
| 434 |
+
|
| 435 |
+
# Nếu chiều cao sau scale vượt quá max_thumbnail_height, crop từ giữa
|
| 436 |
+
if scaled_height > max_thumbnail_height:
|
| 437 |
+
# Crop từ giữa để giữ lại phần quan trọng nhất
|
| 438 |
+
crop_start_y = (scaled_height - max_thumbnail_height) // 2
|
| 439 |
+
crop_end_y = crop_start_y + max_thumbnail_height
|
| 440 |
+
|
| 441 |
+
thumbnail_resized = thumbnail_resized.crop(
|
| 442 |
+
x1=0, y1=crop_start_y,
|
| 443 |
+
x2=target_width, y2=crop_end_y
|
| 444 |
+
)
|
| 445 |
+
actual_thumbnail_height = max_thumbnail_height
|
| 446 |
+
logging.info(f"Thumbnail được crop: từ {scaled_height}px xuống {max_thumbnail_height}px")
|
| 447 |
+
else:
|
| 448 |
+
# Nếu chiều cao sau scale nhỏ hơn max, cập nhật actual height
|
| 449 |
+
actual_thumbnail_height = scaled_height
|
| 450 |
+
|
| 451 |
+
# Tính lại vị trí Y dựa trên chiều cao thực tế
|
| 452 |
+
thumbnail_y_position = target_height - actual_thumbnail_height
|
| 453 |
+
|
| 454 |
+
# Đặt vị trí thumbnail ở dưới cùng, fill width
|
| 455 |
+
thumbnail_positioned = thumbnail_resized.set_position((0, thumbnail_y_position)).set_opacity(0.8)
|
| 456 |
+
overlay_clips.append(thumbnail_positioned)
|
| 457 |
+
|
| 458 |
+
logging.info(f"Thumbnail - Original: {original_thumb_width}x{original_thumb_height}")
|
| 459 |
+
logging.info(f"Thumbnail - Scaled: {target_width}x{scaled_height}")
|
| 460 |
+
logging.info(f"Thumbnail - Final: {target_width}x{actual_thumbnail_height}")
|
| 461 |
+
logging.info(f"Thumbnail - Position: (0, {thumbnail_y_position})")
|
| 462 |
+
else:
|
| 463 |
+
# Fallback nếu không lấy được kích thước
|
| 464 |
+
thumbnail_resized = thumbnail_clip.resize((target_width, max_thumbnail_height))
|
| 465 |
+
thumbnail_positioned = thumbnail_resized.set_position((0, thumbnail_y_position)).set_opacity(0.8)
|
| 466 |
+
overlay_clips.append(thumbnail_positioned)
|
| 467 |
+
logging.warning(f"Sử dụng fallback resize cho thumbnail: {thumbnail_path}")
|
| 468 |
+
|
| 469 |
+
except Exception as e:
|
| 470 |
+
logging.warning(f"Không thể xử lý thumbnail '{thumbnail_path}': {e}")
|
| 471 |
+
|
| 472 |
+
# 3. Xử lý logo - đặt ở góc phải trên
|
| 473 |
+
if logo_path and os.path.exists(logo_path):
|
| 474 |
+
try:
|
| 475 |
+
logo_clip = ImageClip(logo_path, duration=duration)
|
| 476 |
+
|
| 477 |
+
# Resize logo theo tỷ lệ
|
| 478 |
+
logo_width = int(target_width * LOGO_SIZE_RATIO)
|
| 479 |
+
logo_resized = logo_clip.resize(width=logo_width)
|
| 480 |
+
|
| 481 |
+
# Đặt vị trí logo ở góc phải trên
|
| 482 |
+
logo_x = target_width - logo_resized.w - LOGO_MARGIN
|
| 483 |
+
logo_y = LOGO_MARGIN + 50
|
| 484 |
+
logo_positioned = logo_resized.set_position((logo_x, logo_y))
|
| 485 |
+
overlay_clips.append(logo_positioned)
|
| 486 |
+
|
| 487 |
+
logging.info(f"Logo - Size: {logo_resized.w}x{logo_resized.h} - Position: ({logo_x}, {logo_y})")
|
| 488 |
+
|
| 489 |
+
except Exception as e:
|
| 490 |
+
logging.warning(f"Không thể xử lý logo '{logo_path}': {e}")
|
| 491 |
+
|
| 492 |
+
# 4. Thêm text description - đặt TRONG vùng thumbnail với chất lượng cao và BOLD
|
| 493 |
+
if description and description.strip():
|
| 494 |
+
try:
|
| 495 |
+
# Tính toán vị trí text trong vùng thumbnail
|
| 496 |
+
desc_y = thumbnail_y_position + TEXT_MARGIN + 40 # Bên trong thumbnail, từ trên xuống
|
| 497 |
+
|
| 498 |
+
# Font size lớn hơn và responsive
|
| 499 |
+
font_size = max(int(target_width * 0.035), 35) # Tăng từ 0.025 lên 0.035, minimum 50px
|
| 500 |
+
|
| 501 |
+
# Sử dụng font BOLD
|
| 502 |
+
desc_clip = TextClip(
|
| 503 |
+
description.strip(),
|
| 504 |
+
fontsize=font_size,
|
| 505 |
+
font='Arial-Bold', # Sử dụng Arial Bold để chữ dày
|
| 506 |
+
color='white',
|
| 507 |
+
method='caption',
|
| 508 |
+
align='West', # Căn trái
|
| 509 |
+
size=(target_width - 4 * TEXT_MARGIN, None) # Tăng margin để text không bị sát mép
|
| 510 |
+
).set_duration(duration).set_position((2 * TEXT_MARGIN, desc_y))
|
| 511 |
+
|
| 512 |
+
overlay_clips.append(desc_clip)
|
| 513 |
+
logging.info(f"Description text - Font: Arial-Bold {font_size}px")
|
| 514 |
+
|
| 515 |
+
except Exception as e:
|
| 516 |
+
logging.warning(f"Không thể tạo description text: {e}")
|
| 517 |
+
|
| 518 |
+
# 5. Thêm text date - đặt ở góc phải trên của thumbnail với chất lượng cao
|
| 519 |
+
if date and date.strip():
|
| 520 |
+
try:
|
| 521 |
+
# Tính toán vị trí date ở góc phải trên của thumbnail
|
| 522 |
+
date_y = thumbnail_y_position + TEXT_MARGIN # Cùng hàng với description
|
| 523 |
+
|
| 524 |
+
# Font size cho date
|
| 525 |
+
date_font_size = max(int(target_width * 0.025), 20) # Tăng minimum lên 35px
|
| 526 |
+
|
| 527 |
+
# Tạo date clip để tính toán width chính xác
|
| 528 |
+
date_clip = TextClip(
|
| 529 |
+
date.strip(),
|
| 530 |
+
fontsize=date_font_size,
|
| 531 |
+
font='Arial-Bold', # Sử dụng Arial Bold cho date cũng
|
| 532 |
+
color='white',
|
| 533 |
+
method='label', # Sử dụng 'label' thay vì default để tránh blur
|
| 534 |
+
align='center'
|
| 535 |
+
).set_duration(duration)
|
| 536 |
+
|
| 537 |
+
# Tính toán vị trí X để đặt ở góc phải
|
| 538 |
+
date_x = target_width - date_clip.w - 2 * TEXT_MARGIN
|
| 539 |
+
date_positioned = date_clip.set_position((date_x, date_y))
|
| 540 |
+
|
| 541 |
+
overlay_clips.append(date_positioned)
|
| 542 |
+
logging.info(f"Date text - Font: Arial-Bold {date_font_size}px - Position: ({date_x}, {date_y})")
|
| 543 |
+
|
| 544 |
+
except Exception as e:
|
| 545 |
+
logging.warning(f"Không thể tạo date text: {e}")
|
| 546 |
+
|
| 547 |
+
# 6. Composite tất cả các elements
|
| 548 |
+
thumbnail_overlay = CompositeVideoClip(overlay_clips, size=target_resolution)
|
| 549 |
+
|
| 550 |
+
return thumbnail_overlay
|
| 551 |
+
|
| 552 |
+
def rate_media_compatibility(clip, target_aspect_ratio, media_type='image'):
|
| 553 |
+
"""
|
| 554 |
+
Đánh giá độ phù hợp của media với target aspect ratio.
|
| 555 |
+
Trả về score từ 0-100, càng cao càng phù hợp.
|
| 556 |
+
"""
|
| 557 |
+
original_width, original_height = get_media_dimensions(clip)
|
| 558 |
+
|
| 559 |
+
if not original_width or not original_height:
|
| 560 |
+
return 50 # Score trung bình nếu không xác định được
|
| 561 |
+
|
| 562 |
+
original_aspect_ratio = calculate_aspect_ratio(original_width, original_height)
|
| 563 |
+
|
| 564 |
+
if not original_aspect_ratio:
|
| 565 |
+
return 50
|
| 566 |
+
|
| 567 |
+
# Tính độ chênh lệch aspect ratio
|
| 568 |
+
ratio_diff = abs(original_aspect_ratio - target_aspect_ratio)
|
| 569 |
+
|
| 570 |
+
# Score dựa trên độ chênh lệch (càng ít chênh lệch càng cao điểm)
|
| 571 |
+
base_score = max(0, 100 - (ratio_diff * 50))
|
| 572 |
+
|
| 573 |
+
# Bonus cho video (thường linh hoạt hơn ảnh)
|
| 574 |
+
if media_type == 'video':
|
| 575 |
+
base_score += 10
|
| 576 |
+
|
| 577 |
+
# Penalty nếu resolution quá thấp
|
| 578 |
+
min_dimension = min(original_width, original_height)
|
| 579 |
+
if min_dimension < 480:
|
| 580 |
+
base_score -= 30
|
| 581 |
+
elif min_dimension < 720:
|
| 582 |
+
base_score -= 15
|
| 583 |
+
|
| 584 |
+
# Bonus nếu media có thể fit tốt (ít vùng trống)
|
| 585 |
+
scale_by_width = 1080 / original_width # Giả sử target là 1080p
|
| 586 |
+
scale_by_height = 1080 / original_height
|
| 587 |
+
scale_factor = min(scale_by_width, scale_by_height)
|
| 588 |
+
|
| 589 |
+
# Tính % diện tích media chiếm trong target frame
|
| 590 |
+
fitted_area = (original_width * scale_factor) * (original_height * scale_factor)
|
| 591 |
+
target_area = 1080 * (1080 * target_aspect_ratio if target_aspect_ratio < 1 else 1080)
|
| 592 |
+
area_ratio = fitted_area / target_area
|
| 593 |
+
|
| 594 |
+
# Bonus nếu media chiếm nhiều diện tích (ít vùng trống)
|
| 595 |
+
if area_ratio > 0.8:
|
| 596 |
+
base_score += 15
|
| 597 |
+
elif area_ratio > 0.6:
|
| 598 |
+
base_score += 10
|
| 599 |
+
elif area_ratio < 0.3:
|
| 600 |
+
base_score -= 10
|
| 601 |
+
|
| 602 |
+
return max(0, min(100, base_score))
|
| 603 |
+
|
| 604 |
+
# --- CÁC HÀM XỬ LÝ VIDEO ---
|
| 605 |
+
# (Các phần import, hằng số, hàm hỗ trợ khác giữ nguyên)
|
| 606 |
+
|
| 607 |
+
# --- HÀM ĐÃ ĐƯỢC SỬA ĐỔI ---
|
| 608 |
+
def create_clips_from_media_files(
|
| 609 |
+
media_files: list[str],
|
| 610 |
+
target_duration: float,
|
| 611 |
+
aspect_ratio_key: str,
|
| 612 |
+
resolution: tuple[int, int]
|
| 613 |
+
) -> list:
|
| 614 |
+
"""
|
| 615 |
+
Chọn và tạo các clip (ảnh hoặc video) từ danh sách media,
|
| 616 |
+
ưu tiên các media có aspect ratio phù hợp.
|
| 617 |
+
LUÔN BẮT ĐẦU BẰNG MỘT ẢNH (nếu có ảnh).
|
| 618 |
+
- Video sẽ được xử lý bằng smart_crop_to_fill (cắt để lấp đầy) - KHÔNG CÓ ZOOM ANIMATION
|
| 619 |
+
- Ảnh sẽ được xử lý bằng create_zoom_animation (zoom animation động, FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH)
|
| 620 |
+
"""
|
| 621 |
+
if not media_files:
|
| 622 |
+
logging.warning("Không có file media nào được cung cấp.")
|
| 623 |
+
return []
|
| 624 |
+
target_width, target_height = resolution
|
| 625 |
+
target_aspect_ratio = target_width / target_height
|
| 626 |
+
valid_images = []
|
| 627 |
+
valid_videos = []
|
| 628 |
+
|
| 629 |
+
# 1. Phân loại và đánh giá media
|
| 630 |
+
for media_path in media_files:
|
| 631 |
+
if not os.path.exists(media_path):
|
| 632 |
+
logging.warning(f"File media không tồn tại, bỏ qua: {media_path}")
|
| 633 |
+
continue
|
| 634 |
+
ext = media_path.lower().split('.')[-1]
|
| 635 |
+
clip_obj = None
|
| 636 |
+
media_type = None
|
| 637 |
+
try:
|
| 638 |
+
if ext in ['mp4', 'mov', 'avi', 'mkv', 'webm']:
|
| 639 |
+
clip_obj = VideoFileClip(media_path)
|
| 640 |
+
media_type = 'video'
|
| 641 |
+
elif ext in ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff']:
|
| 642 |
+
clip_obj = ImageClip(media_path, duration=3.0) # Duration tạm thời cho ảnh
|
| 643 |
+
media_type = 'image'
|
| 644 |
+
else:
|
| 645 |
+
logging.warning(f"Định dạng file media không hỗ trợ, bỏ qua: {media_path}")
|
| 646 |
+
continue
|
| 647 |
+
|
| 648 |
+
if clip_obj:
|
| 649 |
+
# Đánh giá độ phù hợp
|
| 650 |
+
compatibility_score = rate_media_compatibility(clip_obj, target_aspect_ratio, media_type)
|
| 651 |
+
logging.info(f"Media '{media_path}' - Type: {media_type} - Compatibility score: {compatibility_score:.1f}")
|
| 652 |
+
media_data = {
|
| 653 |
+
'clip': clip_obj,
|
| 654 |
+
'type': media_type,
|
| 655 |
+
'original_clip': clip_obj,
|
| 656 |
+
'compatibility_score': compatibility_score,
|
| 657 |
+
'path': media_path
|
| 658 |
+
}
|
| 659 |
+
if media_type == 'image':
|
| 660 |
+
valid_images.append(media_data)
|
| 661 |
+
else:
|
| 662 |
+
valid_videos.append(media_data)
|
| 663 |
+
except Exception as e:
|
| 664 |
+
logging.warning(f"Không thể xử lý file media '{media_path}': {e}. Bỏ qua.")
|
| 665 |
+
if clip_obj:
|
| 666 |
+
clip_obj.close()
|
| 667 |
+
|
| 668 |
+
# Sắp xếp ảnh theo điểm phù hợp giảm dần để chọn ảnh tốt nhất làm đầu tiên
|
| 669 |
+
valid_images.sort(key=lambda x: x['compatibility_score'], reverse=True)
|
| 670 |
+
|
| 671 |
+
if not valid_images and not valid_videos:
|
| 672 |
+
logging.warning("Không tìm thấy file media nào hợp lệ sau khi xử lý.")
|
| 673 |
+
return []
|
| 674 |
+
|
| 675 |
+
# 2. Chọn clip đầu tiên (luôn là ảnh nếu có)
|
| 676 |
+
clips_for_concatenation = []
|
| 677 |
+
total_clip_duration = 0.0
|
| 678 |
+
first_clip_added = False
|
| 679 |
+
|
| 680 |
+
# Chọn ảnh đầu tiên (ảnh có điểm cao nhất)
|
| 681 |
+
if valid_images:
|
| 682 |
+
first_image_data = valid_images.pop(0) # Lấy ảnh tốt nhất ra khỏi danh sách
|
| 683 |
+
# Xác định thời lượng cho ảnh đầu tiên (ví dụ: 6-8 giây)
|
| 684 |
+
clip_duration = random.uniform(6.0, 8.0)
|
| 685 |
+
remaining_duration = target_duration - total_clip_duration
|
| 686 |
+
if clip_duration > remaining_duration:
|
| 687 |
+
clip_duration = remaining_duration
|
| 688 |
+
|
| 689 |
+
# Xử lý ảnh đầu tiên
|
| 690 |
+
zoom_type = random.choice(['in_out', 'in', 'out'])
|
| 691 |
+
processed_clip = create_zoom_animation(first_image_data['clip'], resolution, clip_duration, zoom_type)
|
| 692 |
+
clips_for_concatenation.append(processed_clip)
|
| 693 |
+
total_clip_duration += clip_duration
|
| 694 |
+
logging.info(f"[ĐẦU TIÊN] Đã thêm ảnh: {first_image_data['path']} - Duration: {clip_duration:.1f}s - Score: {first_image_data['compatibility_score']:.1f}")
|
| 695 |
+
first_clip_added = True
|
| 696 |
+
# Đảm bảo giải phóng tài nguyên cho ảnh đã chọn
|
| 697 |
+
first_image_data['clip'].close()
|
| 698 |
+
|
| 699 |
+
# 3. Kết hợp danh sách media còn lại (ảnh + video) để chọn tiếp theo
|
| 700 |
+
# Đảm bảo ảnh đầu tiên không bị chọn lại
|
| 701 |
+
remaining_media = valid_videos + valid_images # Ảnh đã chọn bị loại bỏ khỏi valid_images ở bước trên
|
| 702 |
+
if not remaining_media and not first_clip_added:
|
| 703 |
+
logging.warning("Không có media nào để thêm vào video.")
|
| 704 |
+
return clips_for_concatenation # Có thể trả về danh sách rỗng hoặc clip fallback nếu cần
|
| 705 |
+
|
| 706 |
+
# Sắp xếp media còn lại theo điểm phù hợp (cao nhất trước) để ưu tiên tốt hơn khi chọn
|
| 707 |
+
remaining_media.sort(key=lambda x: x['compatibility_score'], reverse=True)
|
| 708 |
+
# Trộn ngẫu nhiên thứ tự phát trong số các media còn lại (tuỳ chọn, có thể bỏ nếu muốn theo thứ tự điểm)
|
| 709 |
+
# random.shuffle(remaining_media)
|
| 710 |
+
|
| 711 |
+
# 4. Duyệt qua media còn lại và thêm vào danh sách phát
|
| 712 |
+
for i, media_data in enumerate(remaining_media):
|
| 713 |
+
# Kiểm tra thời lượng còn lại
|
| 714 |
+
remaining_duration = target_duration - total_clip_duration
|
| 715 |
+
if remaining_duration <= 0:
|
| 716 |
+
break
|
| 717 |
+
|
| 718 |
+
clip = media_data['clip']
|
| 719 |
+
# Lấy/gán thời lượng cho clip
|
| 720 |
+
if media_data['type'] == 'image':
|
| 721 |
+
# Đối với ảnh, tăng thời gian lên 6-8 giây để animation chậm đẹp hơn
|
| 722 |
+
clip_duration = random.uniform(6.0, 8.0)
|
| 723 |
+
else: # video
|
| 724 |
+
# Đối với video, lấy thời lượng gốc
|
| 725 |
+
clip_duration = getattr(media_data['original_clip'], 'duration', 0)
|
| 726 |
+
|
| 727 |
+
# Nếu thời lượng còn lại ít hơn thời lượng clip, điều chỉnh
|
| 728 |
+
if clip_duration > remaining_duration:
|
| 729 |
+
clip_duration = remaining_duration
|
| 730 |
+
|
| 731 |
+
# Áp dụng xử lý khác nhau cho video và ảnh
|
| 732 |
+
final_clip = None
|
| 733 |
+
if media_data['type'] == 'video':
|
| 734 |
+
# Video: Sử dụng smart_crop_to_fill để cắt và lấp đầy - KHÔNG CÓ ZOOM ANIMATION
|
| 735 |
+
processed_clip = smart_crop_to_fill(clip, resolution, media_data['type'], fill_color=(20, 20, 20))
|
| 736 |
+
final_clip = processed_clip.set_duration(clip_duration)
|
| 737 |
+
logging.info(f"Video được xử lý bằng smart_crop_to_fill (KHÔNG CÓ ZOOM ANIMATION): {media_data['path']}")
|
| 738 |
+
else: # image
|
| 739 |
+
# Ảnh: Sử dụng create_zoom_animation với FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH
|
| 740 |
+
zoom_types = ['in_out', 'in', 'out']
|
| 741 |
+
zoom_type = random.choice(zoom_types)
|
| 742 |
+
processed_clip = create_zoom_animation(clip, resolution, clip_duration, zoom_type)
|
| 743 |
+
final_clip = processed_clip
|
| 744 |
+
logging.info(f"Ảnh được xử lý bằng zoom animation FILL + GIỮ WIDTH ({zoom_type}): {media_data['path']}")
|
| 745 |
+
|
| 746 |
+
if final_clip:
|
| 747 |
+
clips_for_concatenation.append(final_clip)
|
| 748 |
+
total_clip_duration += clip_duration
|
| 749 |
+
logging.info(f"Đã thêm {media_data['type']}: {media_data['path']} - Duration: {clip_duration:.1f}s - Score: {media_data['compatibility_score']:.1f}")
|
| 750 |
+
|
| 751 |
+
# Nếu đã đủ thời lượng, dừng lại
|
| 752 |
+
if total_clip_duration >= target_duration:
|
| 753 |
+
break
|
| 754 |
+
|
| 755 |
+
# 5. Thêm hiệu ứng chuyển cảnh giữa các clips - LOẠI BỎ ZOOM TRANSITION
|
| 756 |
+
if len(clips_for_concatenation) > 1:
|
| 757 |
+
logging.info("Đang thêm hiệu ứng chuyển cảnh...")
|
| 758 |
+
# LOẠI BỎ 'zoom' ra khỏi transition_types
|
| 759 |
+
transition_types = ['fade', 'slide_left', 'slide_right'] # Không có 'zoom' nữa
|
| 760 |
+
clips_with_transitions = []
|
| 761 |
+
for i, clip in enumerate(clips_for_concatenation):
|
| 762 |
+
if i == 0:
|
| 763 |
+
# Clip đầu tiên, không cần transition
|
| 764 |
+
clips_with_transitions.append(clip)
|
| 765 |
+
else:
|
| 766 |
+
# Thêm transition với clip trước đó
|
| 767 |
+
transition_type = random.choice(transition_types)
|
| 768 |
+
prev_clip = clips_with_transitions[-1]
|
| 769 |
+
# Tạo transition effect
|
| 770 |
+
transitioned_clips = create_transition_effect(
|
| 771 |
+
prev_clip, clip,
|
| 772 |
+
transition_type=transition_type,
|
| 773 |
+
duration=TRANSITION_DURATION
|
| 774 |
+
)
|
| 775 |
+
# Thay thế clip trước đó và thêm clip hiện tại
|
| 776 |
+
if len(transitioned_clips) == 2:
|
| 777 |
+
clips_with_transitions[-1] = transitioned_clips[0] # Clip trước đã được modify
|
| 778 |
+
clips_with_transitions.append(transitioned_clips[1]) # Clip hiện tại với transition
|
| 779 |
+
logging.info(f"Đã thêm transition '{transition_type}' giữa clip {i} và {i+1}")
|
| 780 |
+
else:
|
| 781 |
+
clips_with_transitions.append(clip)
|
| 782 |
+
return clips_with_transitions
|
| 783 |
+
return clips_for_concatenation
|
| 784 |
+
|
| 785 |
+
# (Các phần còn lại của file giữ nguyên)
|
| 786 |
+
def create_video_from_audio_and_media(
|
| 787 |
+
audio_path: str,
|
| 788 |
+
source_media_paths: list[str],
|
| 789 |
+
aspect_ratio_key: str,
|
| 790 |
+
output_filename: str = "output_video.mp4",
|
| 791 |
+
version: int = 1,
|
| 792 |
+
output_dir: str = DEFAULT_SAVE_DIR,
|
| 793 |
+
target_height: int = 1080,
|
| 794 |
+
# Tham số mới cho thumbnail overlay
|
| 795 |
+
thumbnail_path: str = None,
|
| 796 |
+
logo_path: str = None,
|
| 797 |
+
description: str = None,
|
| 798 |
+
date: str = None
|
| 799 |
+
) -> str:
|
| 800 |
+
"""
|
| 801 |
+
Tạo một phiên bản video dựa trên audio và các file media (ảnh/video).
|
| 802 |
+
Có thể thêm thumbnail overlay với logo và text trong 5 giây đầu.
|
| 803 |
+
Thumbnail sẽ được scale để fill width (có thể hi sinh height).
|
| 804 |
+
Ảnh sẽ có zoom animation động FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH, video giữ nguyên xử lý crop.
|
| 805 |
+
"""
|
| 806 |
+
if not audio_path or not os.path.exists(audio_path):
|
| 807 |
+
raise FileNotFoundError("File audio không tồn tại hoặc đường dẫn bị thiếu.")
|
| 808 |
+
|
| 809 |
+
if not source_media_paths:
|
| 810 |
+
raise ValueError("Cần ít nhất một file media để tạo video.")
|
| 811 |
+
|
| 812 |
+
try:
|
| 813 |
+
# 1. Lấy độ phân giải
|
| 814 |
+
resolution = get_resolution(aspect_ratio_key, height=target_height)
|
| 815 |
+
|
| 816 |
+
# 2. Tải Audio Clip và lấy duration
|
| 817 |
+
audio_clip = AudioFileClip(audio_path)
|
| 818 |
+
audio_duration = audio_clip.duration
|
| 819 |
+
logging.info(f"Độ dài audio: {audio_duration:.2f} giây")
|
| 820 |
+
|
| 821 |
+
# 3. Tạo các clip từ danh sách media (ảnh/video) với smart processing và animations
|
| 822 |
+
media_clips = create_clips_from_media_files(source_media_paths, audio_duration, aspect_ratio_key, resolution)
|
| 823 |
+
|
| 824 |
+
if not media_clips:
|
| 825 |
+
logging.warning("Không có clip media nào được tạo. Sử dụng fallback.")
|
| 826 |
+
# Tạo video với nền đen nếu không có media
|
| 827 |
+
fallback_clip = ColorClip(
|
| 828 |
+
size=resolution,
|
| 829 |
+
color=(0, 0, 0), # Màu đen
|
| 830 |
+
duration=audio_duration
|
| 831 |
+
).set_fps(24)
|
| 832 |
+
media_clips = [fallback_clip]
|
| 833 |
+
|
| 834 |
+
# 4. Ghép các clip đã được tạo với transitions
|
| 835 |
+
final_video_segment = concatenate_videoclips(media_clips, method="compose")
|
| 836 |
+
|
| 837 |
+
# 5. Đảm bảo duration của video khớp với audio
|
| 838 |
+
if abs(final_video_segment.duration - audio_duration) > 0.1:
|
| 839 |
+
final_video_segment = final_video_segment.set_duration(audio_duration)
|
| 840 |
+
|
| 841 |
+
# 6. Kết hợp audio vào video clip
|
| 842 |
+
final_video = final_video_segment.set_audio(audio_clip)
|
| 843 |
+
THUMBNAIL_SHOW_DURATION = audio_duration
|
| 844 |
+
# 7. Thêm thumbnail overlay nếu có đủ thông tin
|
| 845 |
+
overlay_added = False
|
| 846 |
+
if any([thumbnail_path, logo_path, description, date]):
|
| 847 |
+
try:
|
| 848 |
+
# Tạo thumbnail overlay cho 5 giây đầu
|
| 849 |
+
thumbnail_overlay = create_thumbnail_overlay(
|
| 850 |
+
thumbnail_path=thumbnail_path,
|
| 851 |
+
logo_path=logo_path,
|
| 852 |
+
description=description,
|
| 853 |
+
date=date,
|
| 854 |
+
target_resolution=resolution,
|
| 855 |
+
duration=audio_duration #min(THUMBNAIL_SHOW_DURATION, audio_duration)
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
# Composite overlay lên video
|
| 859 |
+
final_video = CompositeVideoClip([final_video, thumbnail_overlay], size=resolution)
|
| 860 |
+
overlay_added = True
|
| 861 |
+
logging.info(f"Đã thêm thumbnail overlay (fill width) cho {min(THUMBNAIL_SHOW_DURATION, audio_duration):.1f} giây đầu")
|
| 862 |
+
|
| 863 |
+
except Exception as e:
|
| 864 |
+
logging.warning(f"Không thể thêm thumbnail overlay: {e}")
|
| 865 |
+
|
| 866 |
+
if not overlay_added:
|
| 867 |
+
logging.info("Không có thumbnail overlay được thêm vào video")
|
| 868 |
+
|
| 869 |
+
# 8. Tạo thư mục output nếu chưa tồn tại
|
| 870 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 871 |
+
output_path = os.path.join(output_dir, f"v{version}_{output_filename}")
|
| 872 |
+
|
| 873 |
+
# 9. Ghi file video ra định dạng MP4
|
| 874 |
+
logging.info(f"Đang ghi file video với version {version} ra: {output_path}")
|
| 875 |
+
final_video.write_videofile(
|
| 876 |
+
output_path,
|
| 877 |
+
codec='libx264',
|
| 878 |
+
audio_codec='aac',
|
| 879 |
+
fps=24,
|
| 880 |
+
threads=4,
|
| 881 |
+
preset='ultrafast',
|
| 882 |
+
logger='bar',
|
| 883 |
+
)
|
| 884 |
+
logging.info(f"Tạo video thành công cho phiên bản {version}!")
|
| 885 |
+
|
| 886 |
+
# Giải phóng tài nguyên
|
| 887 |
+
audio_clip.close()
|
| 888 |
+
final_video_segment.close()
|
| 889 |
+
final_video.close()
|
| 890 |
+
for clip in media_clips:
|
| 891 |
+
if hasattr(clip, 'close') and clip is not None:
|
| 892 |
+
clip.close()
|
| 893 |
+
|
| 894 |
+
return output_path
|
| 895 |
+
|
| 896 |
+
except FileNotFoundError as e:
|
| 897 |
+
logging.error(f"Lỗi file: {e}")
|
| 898 |
+
raise
|
| 899 |
+
except ValueError as e:
|
| 900 |
+
logging.error(f"Lỗi giá trị: {e}")
|
| 901 |
+
raise
|
| 902 |
+
except Exception as e:
|
| 903 |
+
logging.error(f"Lỗi không xác định khi tạo video: {e}")
|
| 904 |
+
import traceback
|
| 905 |
+
logging.error(traceback.format_exc())
|
| 906 |
+
raise
|
| 907 |
+
|
| 908 |
+
# --- HÀM CHÍNH ĐỂ TẠO NHIỀU PHIÊN BẢN VIDEO ---
|
| 909 |
+
def generate_multiple_video_versions(
|
| 910 |
+
audio_path: str,
|
| 911 |
+
source_media_paths: list[str],
|
| 912 |
+
aspect_ratio_key: str,
|
| 913 |
+
num_versions: int = 1,
|
| 914 |
+
base_filename: str = "news_video",
|
| 915 |
+
output_dir: str = DEFAULT_SAVE_DIR,
|
| 916 |
+
target_height: int = 1080,
|
| 917 |
+
# Tham số mới cho thumbnail overlay
|
| 918 |
+
thumbnail_path: str = None,
|
| 919 |
+
logo_path: str = None,
|
| 920 |
+
description: str = None,
|
| 921 |
+
date: str = None
|
| 922 |
+
) -> list[str]:
|
| 923 |
+
"""
|
| 924 |
+
Tạo N phiên bản video khác nhau với các kết hợp media (ảnh/video) ngẫu nhiên.
|
| 925 |
+
Có thể thêm thumbnail overlay với logo và text trong 5 giây đầu.
|
| 926 |
+
Thumbnail sẽ được scale để fill width (có thể hi sinh height).
|
| 927 |
+
Ảnh sẽ có zoom animation động FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH, các clip sẽ có transitions đẹp.
|
| 928 |
+
"""
|
| 929 |
+
if not audio_path or not os.path.exists(audio_path):
|
| 930 |
+
raise FileNotFoundError("File audio không tồn tại hoặc đường dẫn bị thiếu.")
|
| 931 |
+
|
| 932 |
+
if not source_media_paths:
|
| 933 |
+
raise ValueError("Cần ít nhất một file media để tạo video.")
|
| 934 |
+
|
| 935 |
+
if num_versions <= 0:
|
| 936 |
+
raise ValueError("Số phiên bản phải lớn hơn 0.")
|
| 937 |
+
|
| 938 |
+
generated_video_paths = []
|
| 939 |
+
|
| 940 |
+
if aspect_ratio_key not in DEFAULT_ASPECT_RATIOS:
|
| 941 |
+
raise ValueError(f"Tỷ lệ khung hình '{aspect_ratio_key}' không hỗ trợ. Hãy chọn trong: {list(DEFAULT_ASPECT_RATIOS.keys())}")
|
| 942 |
+
|
| 943 |
+
valid_sources = [s for s in source_media_paths if os.path.exists(s)]
|
| 944 |
+
|
| 945 |
+
if not valid_sources:
|
| 946 |
+
raise ValueError("Không tìm thấy file media hợp lệ nào.")
|
| 947 |
+
|
| 948 |
+
logging.info(f"Bắt đầu tạo {num_versions} phiên bản video...")
|
| 949 |
+
logging.info(f"Target aspect ratio: {DEFAULT_ASPECT_RATIOS[aspect_ratio_key]}")
|
| 950 |
+
logging.info("Chế độ xử lý: Video = smart_crop_to_fill, Ảnh = zoom animation FILL + GIỮ WIDTH")
|
| 951 |
+
logging.info("Hiệu ứng: Transitions giữa các clips + thumbnail overlay BOLD")
|
| 952 |
+
logging.info(f"Animation: Zoom range 8%, tốc độ rất chậm, luôn fill + giữ width (trừ {BOTTOM_PADDING}px dưới)")
|
| 953 |
+
|
| 954 |
+
# Log thông tin thumbnail overlay
|
| 955 |
+
if any([thumbnail_path, logo_path, description, date]):
|
| 956 |
+
logging.info("Thumbnail overlay sẽ được thêm với:")
|
| 957 |
+
if thumbnail_path: logging.info(f" - Thumbnail (fill width): {thumbnail_path}")
|
| 958 |
+
if logo_path: logging.info(f" - Logo: {logo_path}")
|
| 959 |
+
if description: logging.info(f" - Description BOLD (trong thumbnail): {description[:50]}...")
|
| 960 |
+
if date: logging.info(f" - Date BOLD (trong thumbnail): {date}")
|
| 961 |
+
logging.info(f" - Duration: {THUMBNAIL_SHOW_DURATION} giây đầu")
|
| 962 |
+
logging.info(" - Thumbnail scaling: Fill width, crop height if needed")
|
| 963 |
+
|
| 964 |
+
try:
|
| 965 |
+
temp_audio_clip = AudioFileClip(audio_path)
|
| 966 |
+
audio_duration = temp_audio_clip.duration
|
| 967 |
+
temp_audio_clip.close()
|
| 968 |
+
except Exception as e:
|
| 969 |
+
raise FileNotFoundError(f"Không thể đọc file audio '{audio_path}': {e}")
|
| 970 |
+
|
| 971 |
+
for i in range(num_versions):
|
| 972 |
+
version_num = i + 1
|
| 973 |
+
try:
|
| 974 |
+
logging.info(f"\n--- ĐANG TẠO PHIÊN BẢN VIDEO THỨ {version_num} ---")
|
| 975 |
+
output_filename = f"{base_filename}_v{version_num}.mp4"
|
| 976 |
+
|
| 977 |
+
video_path = create_video_from_audio_and_media(
|
| 978 |
+
audio_path=audio_path,
|
| 979 |
+
source_media_paths=valid_sources,
|
| 980 |
+
aspect_ratio_key=aspect_ratio_key,
|
| 981 |
+
output_filename=output_filename,
|
| 982 |
+
version=version_num,
|
| 983 |
+
output_dir=output_dir,
|
| 984 |
+
target_height=target_height,
|
| 985 |
+
# Truyền thông tin thumbnail overlay
|
| 986 |
+
thumbnail_path=thumbnail_path,
|
| 987 |
+
logo_path=logo_path,
|
| 988 |
+
description=description,
|
| 989 |
+
date=date
|
| 990 |
+
)
|
| 991 |
+
generated_video_paths.append(video_path)
|
| 992 |
+
|
| 993 |
+
except FileNotFoundError as e:
|
| 994 |
+
logging.error(f"Lỗi file ở phiên bản {version_num}: {e}. Bỏ qua phiên bản này.")
|
| 995 |
+
except ValueError as e:
|
| 996 |
+
logging.error(f"Lỗi giá trị ở phiên bản {version_num}: {e}. Bỏ qua phiên bản này.")
|
| 997 |
+
except Exception as e:
|
| 998 |
+
logging.error(f"Lỗi không xác định ở phiên bản {version_num}: {e}")
|
| 999 |
+
import traceback
|
| 1000 |
+
logging.error(traceback.format_exc())
|
| 1001 |
+
|
| 1002 |
+
logging.info(f"Hoàn tất tạo {len(generated_video_paths)} / {num_versions} phiên bản video.")
|
| 1003 |
+
return generated_video_paths
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
+
# --- KHỐI ĐỂ CHẠY THỬ NGHIỆM MODULE ---
|
| 1007 |
+
if __name__ == '__main__':
|
| 1008 |
+
# Ví dụ sử dụng module để tạo video với thumbnail overlay và animations
|
| 1009 |
+
# audio_file = "./generated_content/audio/podcast_3_1da3ae9d-df2a-400f-8168-b4976bc6e20b.wav"
|
| 1010 |
+
audio_file = "generated_content/audio/news_1_b7a03e41-7a82-43fe-b343-e683aeef2c4d.wav"
|
| 1011 |
+
o = './public/u23-VietNam/media/'
|
| 1012 |
+
source_media = [o + 'output_00' + str(i) + '.mp4' for i in range(9)]
|
| 1013 |
+
source_media.extend(['public/u23-VietNam/media/u23-viet-nam-1-1753065357.jpg',
|
| 1014 |
+
'public/u23-VietNam/media/lich thi dau u23 viet nam.png',
|
| 1015 |
+
'public/u23-VietNam/media/993135543_164183883u23-viet-nam.jpg'])
|
| 1016 |
+
|
| 1017 |
+
# Thông tin thumbnail overlay
|
| 1018 |
+
thumbnail_img = "thumbnails/thumbnail1.png"
|
| 1019 |
+
logo_img = "logo.png" # Đường dẫn logo
|
| 1020 |
+
desc_text = """Ban tổ chức V-League ra tối hậu thư cho Quảng Nam"""
|
| 1021 |
+
date_text = "2025-07-23"
|
| 1022 |
+
|
| 1023 |
+
try:
|
| 1024 |
+
generated_videos = generate_multiple_video_versions(
|
| 1025 |
+
audio_path=audio_file,
|
| 1026 |
+
source_media_paths=source_media,
|
| 1027 |
+
aspect_ratio_key="doc",
|
| 1028 |
+
num_versions=1,
|
| 1029 |
+
base_filename="sports_news_video",
|
| 1030 |
+
output_dir=DEFAULT_SAVE_DIR,
|
| 1031 |
+
target_height=1080,
|
| 1032 |
+
# Thông tin thumbnail overlay
|
| 1033 |
+
thumbnail_path=thumbnail_img,
|
| 1034 |
+
logo_path=logo_img,
|
| 1035 |
+
description=desc_text,
|
| 1036 |
+
date=date_text
|
| 1037 |
+
)
|
| 1038 |
+
print("Các video đã được tạo thành công:", generated_videos)
|
| 1039 |
+
except Exception as e:
|
| 1040 |
+
logging.error(f"Lỗi khi tạo video: {e}")
|