# Tên file: video_processor.py # Mô tả: Module xử lý luồng "Auto Tin Tức Video" # - Video nền: Ghép từ Video Common (Logic Flow 4) # - Text: COPY Y HỆT logic 3-Image từ App.py (Không watermark, Title vàng box đen, Wrap 20) import os import random import asyncio import pandas as pd import math import subprocess import functools import traceback import textwrap # QUAN TRỌNG: Thư viện để ngắt dòng import datetime from googleapiclient.discovery import build from googleapiclient.http import MediaFileUpload # --- CẤU HÌNH --- DEFAULT_VIDEO_COMMON_FOLDER_ID = "1AQ_XhyhGiE77r7BvuV4w-V_lgVaBb0QV" # ID Video Common # --- CÁC HÀM HELPER --- def ffmpeg_escape_text(text: str) -> str: """Escape text cho FFmpeg.""" if not isinstance(text, str): return "" text = text.replace('\\', '\\\\') text = text.replace('%', '%%') text = text.replace(':', '\\:') text = text.replace(',', '\\,') text = text.replace('[', '\\[') text = text.replace(']', '\\]') text = text.replace('"', '\\"') text = text.replace("'", "’") return text def calculate_word_timestamps(text: str, mp3_duration: float) -> list[tuple[str, float, float]]: """Tính toán thời gian hiển thị karaoke.""" words = text.split() if not words: return [] pause_time = (text.count('.') + text.count('?') + text.count('!')) * 0.1 + text.count(',') * 0.05 net_speech_time = max(0, mp3_duration - pause_time) time_per_word = net_speech_time / len(words) if words else 0 timestamps, current_time = [], 0.0 for word in words: start_time = current_time duration = time_per_word current_time += duration if word.endswith(('.', '?', '!')): current_time += 0.1 elif word.endswith(','): current_time += 0.05 timestamps.append((word, start_time, start_time + duration)) return timestamps # --- HÀM LẤY VIDEO COMMON --- async def get_all_videos_from_folder(drive_service, folder_id): """Lấy danh sách tất cả video trong folder.""" videos = [] page_token = None try: while True: query = f"'{folder_id}' in parents and trashed=false and mimeType contains 'video/'" response = drive_service.files().list( q=query, fields='nextPageToken, files(id, name)', pageToken=page_token ).execute() videos.extend(response.get('files', [])) page_token = response.get('nextPageToken') if not page_token: break except Exception as e: print(f"Lỗi lấy video từ Drive: {e}") return videos # --- HÀM CORE: RENDER VIDEO --- async def render_news_video_mixed_logic( video_paths: list, mp3_path: str, image_title: str, # Chỉ dùng Image Title (Màu vàng) karaoke_text: str, font_path: str, # Font thường (Karaoke) title_font_path: str, # Font đậm (Tiêu đề) output_path: str, chat_id: int, send_message_func ): """ Tạo video từ list video clips + audio + text. LOGIC: Video Common + Text Overlay chuẩn (Title Vàng Box Đen + Karaoke). """ async def send_error_log(base_message: str): error_traceback = traceback.format_exc() detailed_error_message = (f"❌ **Lỗi Render Video**\n\n{base_message}\n\n{error_traceback}")[:4000] await send_message_func(chat_id, detailed_error_message) try: # 1. Lấy độ dài Audio loop = asyncio.get_event_loop() ffprobe_cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', mp3_path] result = await loop.run_in_executor(None, functools.partial(subprocess.check_output, ffprobe_cmd, stderr=subprocess.PIPE)) mp3_duration = float(result.decode('utf-8').strip()) # 2. Xử lý Video Inputs (Ghép nối) ffmpeg_inputs = [] for v in video_paths: ffmpeg_inputs.extend(['-i', v]) ffmpeg_inputs.extend(['-i', mp3_path]) # Audio là input cuối cùng num_bg_videos = len(video_paths) filter_complex_parts = [] concat_inputs = "" # Scale & Pad từng video clip về chuẩn 1080x1920 for i in range(num_bg_videos): filter_complex_parts.append( f"[{i}:v]scale=w=1080:h=1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1:1,fps=30[v{i}];" ) concat_inputs += f"[v{i}]" # Nối (Concat) các clip filter_complex_parts.append(f"{concat_inputs}concat=n={num_bg_videos}:v=1:a=0[base_video_raw];") # Cắt đúng bằng thời lượng audio filter_complex_parts.append(f"[base_video_raw]trim=duration={mp3_duration},setpts=PTS-STARTPTS[base_video];") # 3. Xử lý Text & Karaoke escaped_font_path = font_path.replace('\\', '/') escaped_title_font_path = title_font_path.replace('\\', '/') all_text_filters = [] # --- A. TIÊU ĐỀ CHÍNH (IMAGE TITLE) --- # Logic: Màu vàng, Box đen, Font đậm, Tự xuống dòng (Width 20), Căn giữa trên # ĐÃ BỎ WATERMARK PHAPDUYEN.ONLINE if image_title and not pd.isna(image_title): # [CHUẨN] Sử dụng textwrap width=20 như trong app.py (Logic 3 ảnh) title_lines = textwrap.wrap(str(image_title), width=20) font_size = 85 start_y = 300 # Vị trí bắt đầu y=300 line_spacing = 125 # Khoảng cách dòng for i, line in enumerate(title_lines): escaped_line = ffmpeg_escape_text(line) # Sao chép y hệt chuỗi drawtext từ app.py all_text_filters.append( f"drawtext=text='{escaped_line}':fontfile='{escaped_title_font_path}':" f"fontcolor=yellow:fontsize={font_size}:borderw=4:" f"box=1:boxcolor=black@0.5:boxborderw=15:" f"x=(w-text_w)/2:y={start_y + i*line_spacing}:" f"enable='between(t,0,{mp3_duration-1})'" ) # --- B. KARAOKE ĐỘNG (GIỮ NGUYÊN) --- if karaoke_text: try: word_timestamps = calculate_word_timestamps(karaoke_text, mp3_duration) if word_timestamps: VIDEO_HEIGHT = 1920 ANIM_DURATION = 0.4 FONT_SIZE_KARA = 65 MAX_CHARS_PER_LINE_KARA = 38 Y_POS_1 = VIDEO_HEIGHT - 350 Y_POS_2 = VIDEO_HEIGHT - 260 processed_text = karaoke_text.replace('.', '.|||').replace(',', ',|||').replace('?', '?|||').replace('!', '!|||') segments_raw = [seg.strip() for seg in processed_text.split('|||') if seg.strip()] final_lines = [] for segment in segments_raw: if len(segment) > MAX_CHARS_PER_LINE_KARA: wrapped_lines = textwrap.wrap(segment, width=MAX_CHARS_PER_LINE_KARA, break_long_words=False, replace_whitespace=False) final_lines.extend(wrapped_lines) else: final_lines.append(segment) display_groups, i = [], 0 while i < len(final_lines): if i + 1 < len(final_lines): display_groups.append([final_lines[i], final_lines[i+1]]) i += 2 else: display_groups.append([final_lines[i]]) i += 1 current_word_index = 0 for group in display_groups: group_word_count = len(" ".join(group).split()) if group_word_count == 0: continue start_time = word_timestamps[current_word_index][1] next_group_start_index = current_word_index + group_word_count end_time = mp3_duration if next_group_start_index >= len(word_timestamps) else (word_timestamps[next_group_start_index][1] + ANIM_DURATION) line1_raw, line2_raw = group[0], group[1] if len(group) > 1 else "" line1_final, line2_final = line1_raw, line2_raw if len(group) == 1 and len(line1_raw) > MAX_CHARS_PER_LINE_KARA: mid_point = len(line1_raw) // 2 break_point = line1_raw.rfind(' ', 0, mid_point) if break_point == -1: break_point = line1_raw.find(' ', mid_point) if break_point != -1: line1_final, line2_final = line1_raw[:break_point], line1_raw[break_point+1:] local_t_in = f"(t-{start_time})" fade_out_start_time = end_time - ANIM_DURATION local_t_out = f"(t-{fade_out_start_time})" alpha_expr = f"'if(lt({local_t_in},{ANIM_DURATION}),{local_t_in}/{ANIM_DURATION},if(gt(t,{fade_out_start_time}),1-({local_t_out}/{ANIM_DURATION}),1))'" # Karaoke dùng font thường base_style_karaoke = f"fontfile='{escaped_font_path}':fontcolor=white:borderw=3:bordercolor=black@0.9:shadowx=0:shadowy=0:shadowcolor=black@0.7:fontsize={FONT_SIZE_KARA}" escaped_line1 = ffmpeg_escape_text(line1_final) filter1 = f"drawtext=text='{escaped_line1}':{base_style_karaoke}:x=(w-text_w)/2:y={Y_POS_1}:alpha={alpha_expr}:enable='between(t,{start_time},{end_time})'" all_text_filters.append(filter1) if line2_final: escaped_line2 = ffmpeg_escape_text(line2_final) filter2 = f"drawtext=text='{escaped_line2}':{base_style_karaoke}:x=(w-text_w)/2:y={Y_POS_2}:alpha={alpha_expr}:enable='between(t,{start_time},{end_time})'" all_text_filters.append(filter2) current_word_index += group_word_count except Exception as e: print(f"Lỗi tạo karaoke: {e}") # 4. Kết hợp Filters text_filter_chain = ",".join(all_text_filters) if text_filter_chain: filter_complex_parts.append(f"[base_video]{text_filter_chain}[final_video]") else: filter_complex_parts.append(f"[base_video]null[final_video]") full_filter_complex = "".join(filter_complex_parts) # 5. Thực thi FFmpeg filter_script_path = output_path.replace('.mp4', '_filter.txt') with open(filter_script_path, 'w', encoding='utf-8') as f: f.write(full_filter_complex) audio_index = num_bg_videos # Audio input index ffmpeg_cmd = [ 'ffmpeg', '-y', *ffmpeg_inputs, '-filter_complex_script', filter_script_path, '-map', '[final_video]', '-map', f'{audio_index}:a', '-filter:a', "atempo=1.10", # Tăng tốc audio nhẹ giống app.py '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '192k', '-t', str(mp3_duration / 1.10), # Điều chỉnh thời lượng theo atempo '-s', '1080x1920', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', output_path ] process = await asyncio.create_subprocess_exec(*ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) _, stderr = await process.communicate() if os.path.exists(filter_script_path): os.remove(filter_script_path) if process.returncode != 0: raise subprocess.CalledProcessError(process.returncode, ffmpeg_cmd, stderr=stderr) return output_path except Exception as e: await send_error_log(str(e)) return None # --- BATCH PROCESSOR --- # --- BATCH PROCESSOR --- async def batch_process_news_videos(chat_id: int, dependencies: dict): send_msg = dependencies['send_telegram_message'] try: # Lấy dependencies get_drive_credentials = dependencies['get_drive_credentials'] download_file = dependencies['download_file_from_drive'] upload_link = dependencies['upload_file_to_drive_and_get_link'] update_cell = dependencies['update_excel_cell'] convert_mp3_vi = dependencies['convert_text_to_mp3'] convert_mp3_en = dependencies['convert_text_to_mp3_en'] upload_yt = dependencies['upload_to_youtube'] send_video = dependencies['send_telegram_video'] # [QUAN TRỌNG] Lấy hàm upload file Excel upload_file_drive = dependencies['upload_file_to_drive'] WORKING_PATH = dependencies['WORKING_EXCEL_PATH'] EXCEL_ID = dependencies['DRIVE_EXCEL_FILE_ID'] TEMP_DIR = dependencies['TEMP_PATH'] MP3_DIR = dependencies['MP3_SAVE_PATH'] # Font FONT_PATH = dependencies['FONT_FILE_PATH'] TITLE_FONT_PATH = os.path.join(dependencies['TEMP_PATH'].replace("Temp/", ""), "UVNTinTuc_B.TTF") if not os.path.exists(TITLE_FONT_PATH): TITLE_FONT_PATH = FONT_PATH # [UPDATE] LẤY 2 ID THƯ MỤC RIÊNG BIỆT VIDEO_FOLDER_ID_VI = dependencies.get('VIDEO_COMMON_FOLDER_ID_VI') VIDEO_FOLDER_ID_EN = dependencies.get('VIDEO_COMMON_FOLDER_ID_EN') creds = await get_drive_credentials(chat_id) service = build('drive', 'v3', credentials=creds) await download_file(service, EXCEL_ID, WORKING_PATH) df = pd.read_excel(WORKING_PATH, sheet_name='DanTri', header=None) # Lọc dòng NYS_VIDEO và xóa khoảng trắng thừa rows = df[df.iloc[:, 13].astype(str).str.upper().str.strip() == 'NYS_VIDEO'] if rows.empty: await send_msg(chat_id, "ℹ️ Không tìm thấy hàng 'NYS_VIDEO' nào.") return await send_msg(chat_id, f"🎬 Bắt đầu dựng {len(rows)} video...") # [UPDATE] TẢI DANH SÁCH VIDEO TỪ CẢ 2 NGUỒN (CACHE) # Tải list video Tiếng Việt common_videos_vi = [] if VIDEO_FOLDER_ID_VI: common_videos_vi = await get_all_videos_from_folder(service, VIDEO_FOLDER_ID_VI) # Tải list video Tiếng Anh common_videos_en = [] if VIDEO_FOLDER_ID_EN: common_videos_en = await get_all_videos_from_folder(service, VIDEO_FOLDER_ID_EN) if not common_videos_vi and not common_videos_en: await send_msg(chat_id, f"❌ Lỗi: Cả 2 thư mục Video Common đều trống hoặc chưa cấu hình.") return for index, row in rows.iterrows(): row_idx = index + 1 title = row[1] image_title = row[2] tts_text = row[4] category = row[0] # Xác định ngôn ngữ lang = 'en' if str(category).endswith('_EN') else 'vi' await send_msg(chat_id, f"⚙️ Đang xử lý: {title} ({lang})") temp_files = [] try: # Chọn nguồn video source_videos = common_videos_vi if lang == 'vi' else common_videos_en if not source_videos: await send_msg(chat_id, f"⚠️ Cảnh báo: Không có video common cho ngôn ngữ '{lang}'.") continue # 1. Tạo TTS if lang == 'vi': wav_path, err = await convert_mp3_vi(tts_text, chat_id) else: wav_path, err = await convert_mp3_en( tts_text, chat_id, dependencies['GEMINI_KEYS'], TEMP_DIR, MP3_DIR, send_msg, dependencies['_split_sentences'] ) if err or not wav_path: raise ValueError(f"Lỗi TTS: {err}") temp_files.append(wav_path) mp3_link, mp3_id = await upload_link(service, wav_path, dependencies['DRIVE_GENERATED_MP3_FOLDER_ID']) await update_cell(WORKING_PATH, "DanTri", f"Q{row_idx}", mp3_link) await update_cell(WORKING_PATH, "DanTri", f"R{row_idx}", mp3_id) # 2. Tính toán số lượng video loop = asyncio.get_event_loop() ffprobe_cmd = ['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', wav_path] res = await loop.run_in_executor(None, functools.partial(subprocess.check_output, ffprobe_cmd, stderr=subprocess.PIPE)) duration = float(res.decode().strip()) num_clips = int(math.ceil(duration / 12.0)) # 3. Chọn Video Common (Từ nguồn đã chọn) selected_metas = random.sample(source_videos, min(num_clips, len(source_videos))) while len(selected_metas) < num_clips: selected_metas.append(random.choice(source_videos)) clip_paths = [] for meta in selected_metas: v_path = os.path.join(TEMP_DIR, f"common_{meta['id']}.mp4") if not os.path.exists(v_path): await download_file(service, meta['id'], v_path) clip_paths.append(v_path) temp_files.append(v_path) # 4. Render Video output_video = os.path.join(dependencies['MP4_SAVE_PATH'], f"autonews_video_{row_idx}_{datetime.datetime.now().strftime('%H%M%S')}.mp4") final_path = await render_news_video_mixed_logic( video_paths=clip_paths, mp3_path=wav_path, image_title=image_title, karaoke_text=tts_text, font_path=FONT_PATH, title_font_path=TITLE_FONT_PATH, output_path=output_video, chat_id=chat_id, send_message_func=send_msg ) if not final_path: raise ValueError("Render Video thất bại.") temp_files.append(final_path) # 5. Gửi & Upload await send_video(chat_id, final_path, f"Video hoàn thiện: {title}") uploaded = await upload_yt( row[12], final_path, title, row[3], chat_id, lang ) if uploaded: v_link, v_id = await upload_link(service, final_path, dependencies['DRIVE_GENERATED_VIDEO_FOLDER_ID']) await update_cell(WORKING_PATH, "DanTri", f"O{row_idx}", v_link) await update_cell(WORKING_PATH, "DanTri", f"P{row_idx}", v_id) # Update Trạng thái Finished (Cột N) await update_cell(WORKING_PATH, "DanTri", f"N{row_idx}", "Finished") # [QUAN TRỌNG] Upload file Excel lên Drive NGAY LẬP TỨC await upload_file_drive(service, WORKING_PATH, EXCEL_ID) await send_msg(chat_id, f"✅ Đã xong bài: {title} (Đã lưu trạng thái)") except Exception as e: await send_msg(chat_id, f"❌ Lỗi xử lý bài '{title}': {e}") await update_cell(WORKING_PATH, "DanTri", f"N{row_idx}", f"Error: {e}") await upload_file_drive(service, WORKING_PATH, EXCEL_ID) finally: for f in temp_files: if f and os.path.exists(f) and "common_" not in os.path.basename(f): try: os.remove(f) except: pass # Upload lần cuối cùng cho chắc chắn await upload_file_drive(service, WORKING_PATH, EXCEL_ID) await send_msg(chat_id, "🏁 Hoàn tất Batch Auto Video.") except Exception as e: await send_msg(chat_id, f"❌ Lỗi Batch Auto Video: {e}") traceback.print_exc() # --- WORKER: QUÉT TIN (Giữ nguyên) --- async def auto_news_video_worker(chat_id: int, categories: list, languages: list, dependencies: dict): send_msg = dependencies['send_telegram_message'] # Thiết lập đường dẫn file cờ dừng TEMP_DIR = dependencies['TEMP_PATH'] stop_flag_path = os.path.join(TEMP_DIR, f"stop_worker_{chat_id}.flag") if os.path.exists(stop_flag_path): os.remove(stop_flag_path) # [BỔ SUNG] Vòng lặp vô tận để chạy định kỳ while True: # 1. Kiểm tra cờ dừng if os.path.exists(stop_flag_path): os.remove(stop_flag_path) await send_msg(chat_id, "✅ Tác vụ Auto Video đã được dừng.") break await send_msg(chat_id, f"🎥 (Auto Video) Bắt đầu chu kỳ quét tin tức...") try: get_drive_credentials = dependencies['get_drive_credentials'] download_file = dependencies['download_file_from_drive'] scrape_func = dependencies['scrape_dantri_category'] filter_func = dependencies['auto_filter_new_articles'] rewrite_vi = dependencies['rewrite_content_with_gemini'] rewrite_en = dependencies['rewrite_content_with_gemini_en'] append_excel = dependencies['append_row_to_excel'] upload_excel = dependencies['upload_file_to_drive'] WORKING_PATH = dependencies['WORKING_EXCEL_PATH'] EXCEL_ID = dependencies['DRIVE_EXCEL_FILE_ID'] DANTRI_CATS = dependencies['DANTRI_CATEGORIES'] creds = await get_drive_credentials(chat_id) if not creds: await send_msg(chat_id, "❌ Lỗi: Không lấy được credentials. Dừng worker.") break service = build('drive', 'v3', credentials=creds) await download_file(service, EXCEL_ID, WORKING_PATH) processed_count = 0 for cat in categories: cat_path = DANTRI_CATS.get(cat) articles = await scrape_func(cat_path, num_articles=5) new_articles = await filter_func(articles, cat) if not new_articles: continue await send_msg(chat_id, f"🔥 (Auto Video) Tìm thấy {len(new_articles)} tin mới mục '{cat}'.") for article in new_articles: tasks = {} if 'vi' in languages: tasks['vi'] = asyncio.create_task(rewrite_vi(article['title'], article['content'])) if 'en' in languages: tasks['en'] = asyncio.create_task(rewrite_en(article['title'], article['content'], dependencies['generate_retry'])) results = await asyncio.gather(*tasks.values()) rewritten_map = dict(zip(tasks.keys(), results)) for lang, data in rewritten_map.items(): if not data: continue tts_script = data.get('tts_script', {}) full_tts = ' '.join(filter(None, [tts_script.get('intro'), tts_script.get('main_content'), tts_script.get('outro')])).strip() df_acc = pd.read_excel(WORKING_PATH, sheet_name='AccDanTri', header=None) acc_key = cat if lang == 'vi' else f"{cat}_EN" acc_row = df_acc[df_acc.iloc[:, 0] == acc_key] pickle_file = acc_row.iloc[0, 1] if not acc_row.empty else "default.pickle" row_data = [ acc_key, data.get('youtube_title'), data.get('image_title'), data.get('youtube_description'), full_tts, article['publish_time'], "", "", "", "", "", "", pickle_file, "NYS_VIDEO", "", "", "", "" ] await append_excel(WORKING_PATH, "DanTri", row_data) processed_count += 1 if processed_count > 0: await upload_excel(service, WORKING_PATH, EXCEL_ID) await send_msg(chat_id, f"✅ (Auto Video) Đã lưu {processed_count} bài viết. Bắt đầu dựng video...") await batch_process_news_videos(chat_id, dependencies) else: await send_msg(chat_id, "ℹ️ (Auto Video) Không có bài viết mới nào.") except Exception as e: await send_msg(chat_id, f"❌ Lỗi Worker Auto Video: {e}") traceback.print_exc() # [BỔ SUNG] Chờ 30 phút trước khi chạy lại await send_msg(chat_id, "⏳ (Auto Video) Hoàn tất chu kỳ. Sẽ quét lại sau 30 phút.") await asyncio.sleep(1800)