news2 / video_processor.py
tridanindi's picture
Upload video_processor.py
6d17629 verified
Raw
History Blame Contribute Delete
26.1 kB
# 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)