TDN-M commited on
Commit
43b61a2
·
verified ·
1 Parent(s): 16dd755

Upload 7 files

Browse files
Files changed (8) hide show
  1. .gitattributes +1 -0
  2. audio_processor.py +161 -0
  3. job_manager.py +213 -0
  4. logo.jpg +3 -0
  5. main.py +695 -0
  6. requirements.txt +1 -0
  7. source_matcher.py +365 -0
  8. 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

  • SHA256: 1f98def244108084f90c11d35114eb98203f24fe2e9ea5ae90d3ac03e37a9160
  • Pointer size: 131 Bytes
  • Size of remote file: 338 kB
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}")