|
|
import os |
|
|
import logging |
|
|
import random |
|
|
from moviepy.editor import ( |
|
|
AudioFileClip, ImageClip, concatenate_videoclips, |
|
|
VideoFileClip, ColorClip, CompositeVideoClip, TextClip |
|
|
) |
|
|
from pathlib import Path |
|
|
import math |
|
|
|
|
|
import PIL.Image |
|
|
from moviepy.config import change_settings |
|
|
|
|
|
change_settings({"IMAGEMAGICK_BINARY": r"C:\Program Files\ImageMagick-7.1.2-Q16-HDRI\magick.exe"}) |
|
|
|
|
|
|
|
|
if not hasattr(PIL.Image, 'ANTIALIAS'): |
|
|
PIL.Image.ANTIALIAS = PIL.Image.Resampling.LANCZOS |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
|
|
|
|
|
|
DEFAULT_SAVE_DIR = "generated_video_output" |
|
|
DEFAULT_ASPECT_RATIOS = { |
|
|
"ngang": (16, 9), |
|
|
"doc": (9, 16), |
|
|
"vuong": (1, 1) |
|
|
} |
|
|
|
|
|
|
|
|
THUMBNAIL_SHOW_DURATION = 5.0 |
|
|
THUMBNAIL_HEIGHT_RATIO = 1/3 |
|
|
LOGO_SIZE_RATIO = 0.08 |
|
|
LOGO_MARGIN = 20 |
|
|
TEXT_MARGIN = 20 |
|
|
|
|
|
|
|
|
def get_resolution(aspect_ratio_key: str, height: int = 1080) -> tuple[int, int]: |
|
|
""" |
|
|
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. |
|
|
""" |
|
|
if aspect_ratio_key not in DEFAULT_ASPECT_RATIOS: |
|
|
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())}") |
|
|
|
|
|
width_ratio, height_ratio = DEFAULT_ASPECT_RATIOS[aspect_ratio_key] |
|
|
|
|
|
if height_ratio == 0: |
|
|
raise ValueError("Tỷ lệ chiều cao không được bằng 0.") |
|
|
|
|
|
width = int(height * width_ratio / height_ratio) |
|
|
width = width + (width % 2) |
|
|
|
|
|
logging.info(f"Độ phân giải cho '{aspect_ratio_key}' (height={height}): ({width}, {height})") |
|
|
return (width, height) |
|
|
|
|
|
def get_media_dimensions(clip): |
|
|
""" |
|
|
Lấy kích thước thực tế của media clip |
|
|
""" |
|
|
if hasattr(clip, 'w') and hasattr(clip, 'h'): |
|
|
return clip.w, clip.h |
|
|
elif hasattr(clip, 'size'): |
|
|
return clip.size |
|
|
else: |
|
|
return None, None |
|
|
|
|
|
def calculate_aspect_ratio(width, height): |
|
|
""" |
|
|
Tính tỷ lệ khung hình của media |
|
|
""" |
|
|
if width and height and height != 0: |
|
|
return width / height |
|
|
return None |
|
|
|
|
|
def smart_fit_and_fill(clip, target_resolution, media_type='image', fill_color=(0, 0, 0)): |
|
|
""" |
|
|
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. |
|
|
|
|
|
Args: |
|
|
clip: Video hoặc Image clip cần xử lý |
|
|
target_resolution: (width, height) của target |
|
|
media_type: 'image' hoặc 'video' |
|
|
fill_color: Màu nền cho vùng trống (R, G, B) |
|
|
|
|
|
Returns: |
|
|
Composite clip với media được fit và background được fill |
|
|
""" |
|
|
target_width, target_height = target_resolution |
|
|
|
|
|
|
|
|
original_width, original_height = get_media_dimensions(clip) |
|
|
|
|
|
if not original_width or not original_height: |
|
|
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") |
|
|
return clip.resize(target_resolution) |
|
|
|
|
|
original_aspect_ratio = calculate_aspect_ratio(original_width, original_height) |
|
|
target_aspect_ratio = target_width / target_height |
|
|
|
|
|
if not original_aspect_ratio: |
|
|
logging.warning(f"Không thể tính tỷ lệ khung hình gốc, sử dụng resize thông thường") |
|
|
return clip.resize(target_resolution) |
|
|
|
|
|
logging.info(f"Media gốc: {original_width}x{original_height} (ratio: {original_aspect_ratio:.2f})") |
|
|
logging.info(f"Target: {target_width}x{target_height} (ratio: {target_aspect_ratio:.2f})") |
|
|
|
|
|
|
|
|
if abs(original_aspect_ratio - target_aspect_ratio) < 0.01: |
|
|
logging.info("Tỷ lệ khung hình gốc phù hợp, chỉ cần resize") |
|
|
return clip.resize(target_resolution) |
|
|
|
|
|
|
|
|
scale_by_width = target_width / original_width |
|
|
scale_by_height = target_height / original_height |
|
|
|
|
|
|
|
|
scale_factor = min(scale_by_width, scale_by_height) |
|
|
|
|
|
|
|
|
new_width = int(original_width * scale_factor) |
|
|
new_height = int(original_height * scale_factor) |
|
|
|
|
|
|
|
|
new_width = new_width + (new_width % 2) |
|
|
new_height = new_height + (new_height % 2) |
|
|
|
|
|
logging.info(f"Scale factor: {scale_factor:.3f}") |
|
|
logging.info(f"Media sau khi fit: {new_width}x{new_height}") |
|
|
|
|
|
|
|
|
fitted_clip = clip.resize((new_width, new_height)) |
|
|
|
|
|
|
|
|
x_offset = (target_width - new_width) // 2 |
|
|
y_offset = (target_height - new_height) // 2 |
|
|
|
|
|
logging.info(f"Vị trí center: ({x_offset}, {y_offset})") |
|
|
|
|
|
|
|
|
duration = getattr(clip, 'duration', 1.0) if hasattr(clip, 'duration') else 1.0 |
|
|
background = ColorClip( |
|
|
size=target_resolution, |
|
|
color=fill_color, |
|
|
duration=duration |
|
|
).set_fps(24) |
|
|
|
|
|
|
|
|
fitted_clip = fitted_clip.set_position((x_offset, y_offset)) |
|
|
|
|
|
|
|
|
final_clip = CompositeVideoClip([background, fitted_clip], size=target_resolution) |
|
|
|
|
|
return final_clip |
|
|
|
|
|
def smart_crop_to_fill(clip, target_resolution, media_type='image', fill_color=(255, 255, 255), bottom_padding: int = 160): |
|
|
""" |
|
|
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. |
|
|
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,... |
|
|
|
|
|
Args: |
|
|
clip: Video hoặc Image clip cần xử lý |
|
|
target_resolution: (width, height) đầu ra mong muốn |
|
|
media_type: 'image' hoặc 'video' |
|
|
fill_color: màu nền cho phần trống |
|
|
bottom_padding: chiều cao phần nền đen ở dưới (tính bằng pixel) |
|
|
|
|
|
Returns: |
|
|
CompositeVideoClip đã crop và chèn vào khung |
|
|
""" |
|
|
target_width, target_height = target_resolution |
|
|
effective_crop_height = target_height - bottom_padding |
|
|
|
|
|
original_width, original_height = get_media_dimensions(clip) |
|
|
|
|
|
if not original_width or not original_height: |
|
|
return clip.resize(target_resolution) |
|
|
|
|
|
target_ratio = target_width / effective_crop_height |
|
|
original_ratio = original_width / original_height |
|
|
|
|
|
|
|
|
if original_ratio > target_ratio: |
|
|
new_width = int(original_height * target_ratio) |
|
|
x1 = (original_width - new_width) // 2 |
|
|
x2 = x1 + new_width |
|
|
y1 = 0 |
|
|
y2 = original_height |
|
|
else: |
|
|
new_height = int(original_width / target_ratio) |
|
|
y1 = (original_height - new_height) // 2 |
|
|
y2 = y1 + new_height |
|
|
x1 = 0 |
|
|
x2 = original_width |
|
|
|
|
|
cropped = clip.crop(x1=x1, y1=y1, x2=x2, y2=y2) |
|
|
resized_cropped = cropped.resize((target_width, effective_crop_height)) |
|
|
|
|
|
|
|
|
duration = getattr(clip, 'duration', 1.0) |
|
|
background = ColorClip(size=target_resolution, color=fill_color, duration=duration).set_fps(24) |
|
|
|
|
|
|
|
|
final = CompositeVideoClip([background, resized_cropped.set_position(("center", "top"))], size=target_resolution) |
|
|
|
|
|
return final |
|
|
|
|
|
|
|
|
|
|
|
def create_thumbnail_overlay( |
|
|
thumbnail_path: str, |
|
|
logo_path: str, |
|
|
description: str, |
|
|
date: str, |
|
|
target_resolution: tuple[int, int], |
|
|
duration: float = THUMBNAIL_SHOW_DURATION |
|
|
) -> CompositeVideoClip: |
|
|
""" |
|
|
Tạo overlay thumbnail với logo và text cho 5 giây đầu video. |
|
|
Thumbnail sẽ được scale để CHIỀU RỘNG luôn bao phủ đủ (có thể hi sinh chiều cao). |
|
|
|
|
|
Layout: |
|
|
┌─────────────────────────┐ |
|
|
│ VIDEO │ |
|
|
│ LOGO │ ← Góc phải trên |
|
|
│ │ |
|
|
│ │ |
|
|
├─────────────────────────┤ |
|
|
│ THUMBNAIL DATE │ ← Date ở góc phải trên của thumbnail |
|
|
│ + DESCRIPTION │ (Text overlay trên thumbnail) |
|
|
│ │ |
|
|
└─────────────────────────┘ |
|
|
|
|
|
Args: |
|
|
thumbnail_path: Đường dẫn ảnh thumbnail |
|
|
logo_path: Đường dẫn logo |
|
|
description: Mô tả văn bản (hiển thị trên thumbnail) |
|
|
date: Ngày tháng (hiển thị ở góc phải trên thumbnail) |
|
|
target_resolution: (width, height) của video |
|
|
duration: Thời lượng hiển thị overlay (mặc định 5 giây) |
|
|
|
|
|
Returns: |
|
|
CompositeVideoClip chứa thumbnail overlay |
|
|
""" |
|
|
description = description.upper() |
|
|
target_width, target_height = target_resolution |
|
|
max_thumbnail_height = int(target_height * THUMBNAIL_HEIGHT_RATIO) |
|
|
|
|
|
|
|
|
overlay_background = ColorClip( |
|
|
size=target_resolution, |
|
|
color=(0, 0, 0), |
|
|
duration=duration |
|
|
).set_opacity(0.3) |
|
|
|
|
|
overlay_clips = [overlay_background] |
|
|
|
|
|
|
|
|
actual_thumbnail_height = max_thumbnail_height |
|
|
thumbnail_y_position = target_height - max_thumbnail_height |
|
|
|
|
|
if thumbnail_path and os.path.exists(thumbnail_path): |
|
|
try: |
|
|
thumbnail_clip = ImageClip(thumbnail_path, duration=duration) |
|
|
|
|
|
|
|
|
original_thumb_width, original_thumb_height = get_media_dimensions(thumbnail_clip) |
|
|
|
|
|
if original_thumb_width and original_thumb_height: |
|
|
|
|
|
scale_to_fit_width = target_width / original_thumb_width |
|
|
|
|
|
|
|
|
scaled_height = int(original_thumb_height * scale_to_fit_width) |
|
|
|
|
|
|
|
|
thumbnail_resized = thumbnail_clip.resize((target_width, scaled_height)) |
|
|
|
|
|
|
|
|
if scaled_height > max_thumbnail_height: |
|
|
|
|
|
crop_start_y = (scaled_height - max_thumbnail_height) // 2 |
|
|
crop_end_y = crop_start_y + max_thumbnail_height |
|
|
|
|
|
thumbnail_resized = thumbnail_resized.crop( |
|
|
x1=0, y1=crop_start_y, |
|
|
x2=target_width, y2=crop_end_y |
|
|
) |
|
|
actual_thumbnail_height = max_thumbnail_height |
|
|
logging.info(f"Thumbnail được crop: từ {scaled_height}px xuống {max_thumbnail_height}px") |
|
|
else: |
|
|
|
|
|
actual_thumbnail_height = scaled_height |
|
|
|
|
|
|
|
|
thumbnail_y_position = target_height - actual_thumbnail_height |
|
|
|
|
|
|
|
|
thumbnail_positioned = thumbnail_resized.set_position((0, thumbnail_y_position)).set_opacity(0.8) |
|
|
overlay_clips.append(thumbnail_positioned) |
|
|
|
|
|
logging.info(f"Thumbnail - Original: {original_thumb_width}x{original_thumb_height}") |
|
|
logging.info(f"Thumbnail - Scaled: {target_width}x{scaled_height}") |
|
|
logging.info(f"Thumbnail - Final: {target_width}x{actual_thumbnail_height}") |
|
|
logging.info(f"Thumbnail - Position: (0, {thumbnail_y_position})") |
|
|
else: |
|
|
|
|
|
thumbnail_resized = thumbnail_clip.resize((target_width, max_thumbnail_height)) |
|
|
thumbnail_positioned = thumbnail_resized.set_position((0, thumbnail_y_position)).set_opacity(0.8) |
|
|
overlay_clips.append(thumbnail_positioned) |
|
|
logging.warning(f"Sử dụng fallback resize cho thumbnail: {thumbnail_path}") |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"Không thể xử lý thumbnail '{thumbnail_path}': {e}") |
|
|
|
|
|
|
|
|
if logo_path and os.path.exists(logo_path): |
|
|
try: |
|
|
logo_clip = ImageClip(logo_path, duration=duration) |
|
|
|
|
|
|
|
|
logo_width = int(target_width * LOGO_SIZE_RATIO) |
|
|
logo_resized = logo_clip.resize(width=logo_width) |
|
|
|
|
|
|
|
|
logo_x = target_width - logo_resized.w - LOGO_MARGIN |
|
|
logo_y = LOGO_MARGIN |
|
|
logo_positioned = logo_resized.set_position((logo_x, logo_y)) |
|
|
overlay_clips.append(logo_positioned) |
|
|
|
|
|
logging.info(f"Logo - Size: {logo_resized.w}x{logo_resized.h} - Position: ({logo_x}, {logo_y})") |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"Không thể xử lý logo '{logo_path}': {e}") |
|
|
|
|
|
|
|
|
if description and description.strip(): |
|
|
try: |
|
|
|
|
|
desc_y = thumbnail_y_position + TEXT_MARGIN + 40 |
|
|
|
|
|
|
|
|
font_size = max(int(target_width * 0.035), 50) |
|
|
|
|
|
|
|
|
desc_clip = TextClip( |
|
|
description.strip(), |
|
|
fontsize=font_size, |
|
|
font='Open Sans', |
|
|
color='white', |
|
|
method='label', |
|
|
align='West', |
|
|
size=(target_width - 4 * TEXT_MARGIN, None) |
|
|
).set_duration(duration).set_position((2 * TEXT_MARGIN, desc_y)) |
|
|
|
|
|
overlay_clips.append(desc_clip) |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"Không thể tạo description text: {e}") |
|
|
|
|
|
|
|
|
if date and date.strip(): |
|
|
try: |
|
|
|
|
|
date_y = thumbnail_y_position + TEXT_MARGIN |
|
|
|
|
|
|
|
|
date_font_size = max(int(target_width * 0.025), 20) |
|
|
|
|
|
|
|
|
date_clip = TextClip( |
|
|
date.strip(), |
|
|
fontsize=date_font_size, |
|
|
font='Open Sans', |
|
|
color='white', |
|
|
method='label', |
|
|
align='center' |
|
|
).set_duration(duration) |
|
|
|
|
|
|
|
|
date_x = target_width - date_clip.w - 2 * TEXT_MARGIN |
|
|
date_positioned = date_clip.set_position((date_x, date_y)) |
|
|
|
|
|
overlay_clips.append(date_positioned) |
|
|
logging.info(f"Date text - Font: Arial-Bold {date_font_size}px - Position: ({date_x}, {date_y})") |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"Không thể tạo date text: {e}") |
|
|
|
|
|
|
|
|
thumbnail_overlay = CompositeVideoClip(overlay_clips, size=target_resolution) |
|
|
|
|
|
return thumbnail_overlay |
|
|
def rate_media_compatibility(clip, target_aspect_ratio, media_type='image'): |
|
|
""" |
|
|
Đánh giá độ phù hợp của media với target aspect ratio. |
|
|
Trả về score từ 0-100, càng cao càng phù hợp. |
|
|
""" |
|
|
original_width, original_height = get_media_dimensions(clip) |
|
|
|
|
|
if not original_width or not original_height: |
|
|
return 50 |
|
|
|
|
|
original_aspect_ratio = calculate_aspect_ratio(original_width, original_height) |
|
|
|
|
|
if not original_aspect_ratio: |
|
|
return 50 |
|
|
|
|
|
|
|
|
ratio_diff = abs(original_aspect_ratio - target_aspect_ratio) |
|
|
|
|
|
|
|
|
base_score = max(0, 100 - (ratio_diff * 50)) |
|
|
|
|
|
|
|
|
if media_type == 'video': |
|
|
base_score += 10 |
|
|
|
|
|
|
|
|
min_dimension = min(original_width, original_height) |
|
|
if min_dimension < 480: |
|
|
base_score -= 30 |
|
|
elif min_dimension < 720: |
|
|
base_score -= 15 |
|
|
|
|
|
|
|
|
scale_by_width = 1080 / original_width |
|
|
scale_by_height = 1080 / original_height |
|
|
scale_factor = min(scale_by_width, scale_by_height) |
|
|
|
|
|
|
|
|
fitted_area = (original_width * scale_factor) * (original_height * scale_factor) |
|
|
target_area = 1080 * (1080 * target_aspect_ratio if target_aspect_ratio < 1 else 1080) |
|
|
area_ratio = fitted_area / target_area |
|
|
|
|
|
|
|
|
if area_ratio > 0.8: |
|
|
base_score += 15 |
|
|
elif area_ratio > 0.6: |
|
|
base_score += 10 |
|
|
elif area_ratio < 0.3: |
|
|
base_score -= 10 |
|
|
|
|
|
return max(0, min(100, base_score)) |
|
|
|
|
|
|
|
|
|
|
|
def create_clips_from_media_files( |
|
|
media_files: list[str], |
|
|
target_duration: float, |
|
|
aspect_ratio_key: str, |
|
|
resolution: tuple[int, int] |
|
|
) -> list: |
|
|
""" |
|
|
Chọn và tạo các clip (ảnh hoặc video) từ danh sách media, |
|
|
ưu tiên các media có aspect ratio phù hợp. |
|
|
- Video sẽ được xử lý bằng smart_crop_to_fill (cắt để lấp đầy) |
|
|
- Ảnh sẽ được xử lý bằng smart_fit_and_fill (fit với background) |
|
|
""" |
|
|
if not media_files: |
|
|
logging.warning("Không có file media nào được cung cấp.") |
|
|
return [] |
|
|
|
|
|
target_width, target_height = resolution |
|
|
target_aspect_ratio = target_width / target_height |
|
|
|
|
|
valid_media_objects = [] |
|
|
for media_path in media_files: |
|
|
if not os.path.exists(media_path): |
|
|
logging.warning(f"File media không tồn tại, bỏ qua: {media_path}") |
|
|
continue |
|
|
|
|
|
ext = media_path.lower().split('.')[-1] |
|
|
clip_obj = None |
|
|
media_type = None |
|
|
|
|
|
try: |
|
|
if ext in ['mp4', 'mov', 'avi', 'mkv', 'webm']: |
|
|
clip_obj = VideoFileClip(media_path) |
|
|
media_type = 'video' |
|
|
elif ext in ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff']: |
|
|
clip_obj = ImageClip(media_path, duration=3.0) |
|
|
media_type = 'image' |
|
|
else: |
|
|
logging.warning(f"Định dạng file media không hỗ trợ, bỏ qua: {media_path}") |
|
|
continue |
|
|
|
|
|
|
|
|
compatibility_score = rate_media_compatibility(clip_obj, target_aspect_ratio, media_type) |
|
|
logging.info(f"Media '{media_path}' - Type: {media_type} - Compatibility score: {compatibility_score:.1f}") |
|
|
|
|
|
|
|
|
if media_type == 'video': |
|
|
|
|
|
processed_clip = smart_crop_to_fill(clip_obj, resolution, media_type, fill_color=(20, 20, 20)) |
|
|
logging.info(f"Video được xử lý bằng smart_crop_to_fill: {media_path}") |
|
|
else: |
|
|
|
|
|
processed_clip = smart_fit_and_fill(clip_obj, resolution, media_type, fill_color=(20, 20, 20)) |
|
|
logging.info(f"Ảnh được xử lý bằng smart_fit_and_fill: {media_path}") |
|
|
|
|
|
valid_media_objects.append({ |
|
|
'clip': processed_clip, |
|
|
'type': media_type, |
|
|
'original_clip': clip_obj, |
|
|
'compatibility_score': compatibility_score, |
|
|
'path': media_path |
|
|
}) |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"Không thể xử lý file media '{media_path}': {e}. Bỏ qua.") |
|
|
if clip_obj: |
|
|
clip_obj.close() |
|
|
|
|
|
if not valid_media_objects: |
|
|
logging.warning("Không tìm thấy file media hợp lệ nào sau khi xử lý.") |
|
|
return [] |
|
|
|
|
|
|
|
|
valid_media_objects.sort(key=lambda x: x['compatibility_score'], reverse=True) |
|
|
logging.info("Thứ tự ưu tiên các media:") |
|
|
for i, media in enumerate(valid_media_objects[:5]): |
|
|
logging.info(f" {i+1}. {media['path']} - Type: {media['type']} - Score: {media['compatibility_score']:.1f}") |
|
|
|
|
|
|
|
|
clips_for_concatenation = [] |
|
|
total_clip_duration = 0 |
|
|
|
|
|
|
|
|
selected_media = [] |
|
|
high_score_media = [m for m in valid_media_objects if m['compatibility_score'] >= 70] |
|
|
medium_score_media = [m for m in valid_media_objects if 40 <= m['compatibility_score'] < 70] |
|
|
|
|
|
|
|
|
selected_media = valid_media_objects.copy() |
|
|
random.shuffle(selected_media) |
|
|
|
|
|
|
|
|
total_video_duration = 0 |
|
|
for media_data in selected_media: |
|
|
if media_data['type'] == 'video': |
|
|
total_video_duration += getattr(media_data['original_clip'], 'duration', 0) |
|
|
|
|
|
|
|
|
if total_video_duration < target_duration: |
|
|
|
|
|
pass |
|
|
|
|
|
|
|
|
for media_data in selected_media: |
|
|
clip = media_data['clip'] |
|
|
|
|
|
|
|
|
if media_data['type'] == 'image': |
|
|
|
|
|
clip_duration = 3.0 |
|
|
else: |
|
|
|
|
|
clip_duration = getattr(media_data['original_clip'], 'duration', 0) |
|
|
|
|
|
|
|
|
remaining_duration = target_duration - total_clip_duration |
|
|
if remaining_duration <= 0: |
|
|
break |
|
|
|
|
|
|
|
|
if clip_duration > remaining_duration: |
|
|
clip_duration = remaining_duration |
|
|
|
|
|
|
|
|
final_clip = clip.set_duration(clip_duration) |
|
|
clips_for_concatenation.append(final_clip) |
|
|
total_clip_duration += clip_duration |
|
|
|
|
|
logging.info(f"Đã thêm {media_data['type']}: {media_data['path']} - Duration: {clip_duration:.1f}s - Score: {media_data['compatibility_score']:.1f}") |
|
|
|
|
|
|
|
|
if total_clip_duration >= target_duration: |
|
|
break |
|
|
|
|
|
return clips_for_concatenation |
|
|
|
|
|
def create_video_from_audio_and_media( |
|
|
audio_path: str, |
|
|
source_media_paths: list[str], |
|
|
aspect_ratio_key: str, |
|
|
output_filename: str = "output_video.mp4", |
|
|
version: int = 1, |
|
|
output_dir: str = DEFAULT_SAVE_DIR, |
|
|
target_height: int = 1080, |
|
|
|
|
|
thumbnail_path: str = None, |
|
|
logo_path: str = None, |
|
|
description: str = None, |
|
|
date: str = None |
|
|
) -> str: |
|
|
""" |
|
|
Tạo một phiên bản video dựa trên audio và các file media (ảnh/video). |
|
|
Có thể thêm thumbnail overlay với logo và text trong 5 giây đầu. |
|
|
Thumbnail sẽ được scale để fill width (có thể hi sinh height). |
|
|
""" |
|
|
if not audio_path or not os.path.exists(audio_path): |
|
|
raise FileNotFoundError("File audio không tồn tại hoặc đường dẫn bị thiếu.") |
|
|
|
|
|
if not source_media_paths: |
|
|
raise ValueError("Cần ít nhất một file media để tạo video.") |
|
|
|
|
|
try: |
|
|
|
|
|
resolution = get_resolution(aspect_ratio_key, height=target_height) |
|
|
|
|
|
|
|
|
audio_clip = AudioFileClip(audio_path) |
|
|
audio_duration = audio_clip.duration |
|
|
logging.info(f"Độ dài audio: {audio_duration:.2f} giây") |
|
|
|
|
|
|
|
|
media_clips = create_clips_from_media_files(source_media_paths, audio_duration, aspect_ratio_key, resolution) |
|
|
|
|
|
if not media_clips: |
|
|
logging.warning("Không có clip media nào được tạo. Sử dụng fallback.") |
|
|
|
|
|
fallback_clip = ColorClip( |
|
|
size=resolution, |
|
|
color=(0, 0, 0), |
|
|
duration=audio_duration |
|
|
).set_fps(24) |
|
|
media_clips = [fallback_clip] |
|
|
|
|
|
|
|
|
final_video_segment = concatenate_videoclips(media_clips, method="compose") |
|
|
|
|
|
|
|
|
if abs(final_video_segment.duration - audio_duration) > 0.1: |
|
|
final_video_segment = final_video_segment.set_duration(audio_duration) |
|
|
|
|
|
|
|
|
final_video = final_video_segment.set_audio(audio_clip) |
|
|
|
|
|
|
|
|
overlay_added = False |
|
|
if any([thumbnail_path, logo_path, description, date]): |
|
|
try: |
|
|
|
|
|
thumbnail_overlay = create_thumbnail_overlay( |
|
|
thumbnail_path=thumbnail_path, |
|
|
logo_path=logo_path, |
|
|
description=description, |
|
|
date=date, |
|
|
target_resolution=resolution, |
|
|
duration=min(THUMBNAIL_SHOW_DURATION, audio_duration) |
|
|
) |
|
|
|
|
|
|
|
|
final_video = CompositeVideoClip([final_video, thumbnail_overlay], size=resolution) |
|
|
overlay_added = True |
|
|
logging.info(f"Đã thêm thumbnail overlay (fill width) cho {min(THUMBNAIL_SHOW_DURATION, audio_duration):.1f} giây đầu") |
|
|
|
|
|
except Exception as e: |
|
|
logging.warning(f"Không thể thêm thumbnail overlay: {e}") |
|
|
|
|
|
if not overlay_added: |
|
|
logging.info("Không có thumbnail overlay được thêm vào video") |
|
|
|
|
|
|
|
|
os.makedirs(output_dir, exist_ok=True) |
|
|
output_path = os.path.join(output_dir, f"v{version}_{output_filename}") |
|
|
|
|
|
|
|
|
logging.info(f"Đang ghi file video với version {version} ra: {output_path}") |
|
|
final_video.write_videofile( |
|
|
output_path, |
|
|
codec='libx264', |
|
|
audio_codec='aac', |
|
|
fps=24, |
|
|
threads=4, |
|
|
preset='medium', |
|
|
logger='bar', |
|
|
) |
|
|
logging.info(f"Tạo video thành công cho phiên bản {version}!") |
|
|
|
|
|
|
|
|
audio_clip.close() |
|
|
final_video_segment.close() |
|
|
final_video.close() |
|
|
for clip in media_clips: |
|
|
if hasattr(clip, 'close') and clip is not None: |
|
|
clip.close() |
|
|
|
|
|
return output_path |
|
|
|
|
|
except FileNotFoundError as e: |
|
|
logging.error(f"Lỗi file: {e}") |
|
|
raise |
|
|
except ValueError as e: |
|
|
logging.error(f"Lỗi giá trị: {e}") |
|
|
raise |
|
|
except Exception as e: |
|
|
logging.error(f"Lỗi không xác định khi tạo video: {e}") |
|
|
import traceback |
|
|
logging.error(traceback.format_exc()) |
|
|
raise |
|
|
|
|
|
|
|
|
def generate_multiple_video_versions( |
|
|
audio_path: str, |
|
|
source_media_paths: list[str], |
|
|
aspect_ratio_key: str, |
|
|
num_versions: int = 1, |
|
|
base_filename: str = "news_video", |
|
|
output_dir: str = DEFAULT_SAVE_DIR, |
|
|
target_height: int = 1080, |
|
|
|
|
|
thumbnail_path: str = None, |
|
|
logo_path: str = None, |
|
|
description: str = None, |
|
|
date: str = None |
|
|
) -> list[str]: |
|
|
""" |
|
|
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. |
|
|
Có thể thêm thumbnail overlay với logo và text trong 5 giây đầu. |
|
|
Thumbnail sẽ được scale để fill width (có thể hi sinh height). |
|
|
""" |
|
|
if not audio_path or not os.path.exists(audio_path): |
|
|
raise FileNotFoundError("File audio không tồn tại hoặc đường dẫn bị thiếu.") |
|
|
|
|
|
if not source_media_paths: |
|
|
raise ValueError("Cần ít nhất một file media để tạo video.") |
|
|
|
|
|
if num_versions <= 0: |
|
|
raise ValueError("Số phiên bản phải lớn hơn 0.") |
|
|
|
|
|
generated_video_paths = [] |
|
|
|
|
|
if aspect_ratio_key not in DEFAULT_ASPECT_RATIOS: |
|
|
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())}") |
|
|
|
|
|
valid_sources = [s for s in source_media_paths if os.path.exists(s)] |
|
|
|
|
|
if not valid_sources: |
|
|
raise ValueError("Không tìm thấy file media hợp lệ nào.") |
|
|
|
|
|
logging.info(f"Bắt đầu tạo {num_versions} phiên bản video...") |
|
|
logging.info(f"Target aspect ratio: {DEFAULT_ASPECT_RATIOS[aspect_ratio_key]}") |
|
|
logging.info("Chế độ xử lý: Video = smart_crop_to_fill, Ảnh = smart_fit_and_fill") |
|
|
|
|
|
|
|
|
if any([thumbnail_path, logo_path, description, date]): |
|
|
logging.info("Thumbnail overlay sẽ được thêm với:") |
|
|
if thumbnail_path: logging.info(f" - Thumbnail (fill width): {thumbnail_path}") |
|
|
if logo_path: logging.info(f" - Logo: {logo_path}") |
|
|
if description: logging.info(f" - Description (trong thumbnail): {description[:50]}...") |
|
|
if date: logging.info(f" - Date (trong thumbnail): {date}") |
|
|
logging.info(f" - Duration: {THUMBNAIL_SHOW_DURATION} giây đầu") |
|
|
logging.info(" - Thumbnail scaling: Fill width, crop height if needed") |
|
|
|
|
|
try: |
|
|
temp_audio_clip = AudioFileClip(audio_path) |
|
|
audio_duration = temp_audio_clip.duration |
|
|
temp_audio_clip.close() |
|
|
except Exception as e: |
|
|
raise FileNotFoundError(f"Không thể đọc file audio '{audio_path}': {e}") |
|
|
|
|
|
for i in range(num_versions): |
|
|
version_num = i + 1 |
|
|
try: |
|
|
logging.info(f"\n--- ĐANG TẠO PHIÊN BẢN VIDEO THỨ {version_num} ---") |
|
|
output_filename = f"{base_filename}_v{version_num}.mp4" |
|
|
|
|
|
video_path = create_video_from_audio_and_media( |
|
|
audio_path=audio_path, |
|
|
source_media_paths=valid_sources, |
|
|
aspect_ratio_key=aspect_ratio_key, |
|
|
output_filename=output_filename, |
|
|
version=version_num, |
|
|
output_dir=output_dir, |
|
|
target_height=target_height, |
|
|
|
|
|
thumbnail_path=thumbnail_path, |
|
|
logo_path=logo_path, |
|
|
description=description, |
|
|
date=date |
|
|
) |
|
|
generated_video_paths.append(video_path) |
|
|
|
|
|
except FileNotFoundError as e: |
|
|
logging.error(f"Lỗi file ở phiên bản {version_num}: {e}. Bỏ qua phiên bản này.") |
|
|
except ValueError as e: |
|
|
logging.error(f"Lỗi giá trị ở phiên bản {version_num}: {e}. Bỏ qua phiên bản này.") |
|
|
except Exception as e: |
|
|
logging.error(f"Lỗi không xác định ở phiên bản {version_num}: {e}") |
|
|
import traceback |
|
|
logging.error(traceback.format_exc()) |
|
|
|
|
|
logging.info(f"Hoàn tất tạo {len(generated_video_paths)} / {num_versions} phiên bản video.") |
|
|
return generated_video_paths |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__': |
|
|
|
|
|
|
|
|
audio_file = "generated_content/audio/news_1_7022b2cb-b207-45c6-b24d-144e881996e9.wav" |
|
|
o = './public/u23-VietNam/media/' |
|
|
source_media = [o + 'output_00' + str(i) + '.mp4' for i in range(9)] |
|
|
source_media.extend(['public/u23-VietNam/media/u23-viet-nam-1-1753065357.jpg', |
|
|
'public/u23-VietNam/media/lich thi dau u23 viet nam.png', |
|
|
'public/u23-VietNam/media/993135543_164183883u23-viet-nam.jpg']) |
|
|
|
|
|
|
|
|
thumbnail_img = "thumbnails/thumbnail1.png" |
|
|
logo_img = "logo.png" |
|
|
desc_text = "U23 Việt Nam chuẩn bị cho giải đấu quan trọng" |
|
|
date_text = "2025-07-23" |
|
|
|
|
|
try: |
|
|
generated_videos = generate_multiple_video_versions( |
|
|
audio_path=audio_file, |
|
|
source_media_paths=source_media, |
|
|
aspect_ratio_key="doc", |
|
|
num_versions=1, |
|
|
base_filename="sports_news_video", |
|
|
output_dir=DEFAULT_SAVE_DIR, |
|
|
target_height=1080, |
|
|
|
|
|
thumbnail_path=thumbnail_img, |
|
|
logo_path=logo_img, |
|
|
description=desc_text, |
|
|
date=date_text |
|
|
) |
|
|
print("Các video đã được tạo thành công:", generated_videos) |
|
|
except Exception as e: |
|
|
logging.error(f"Lỗi khi tạo video: {e}") |