Spaces:
Sleeping
Sleeping
| # 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) |