| | 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
|
| | import os
|
| | from moviepy.config import change_settings
|
| |
|
| |
|
| | change_settings({"IMAGEMAGICK_BINARY": "/usr/bin/convert"})
|
| |
|
| |
|
| | 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/4
|
| | LOGO_SIZE_RATIO = 0.16
|
| | LOGO_MARGIN = 20
|
| | TEXT_MARGIN = 20
|
| |
|
| |
|
| | ZOOM_ANIMATION_DURATION = 4.0
|
| | TRANSITION_DURATION = 0.5
|
| | BOTTOM_PADDING = 160
|
| |
|
| |
|
| | 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 create_zoom_animation(clip, target_resolution, duration, zoom_type='in_out'):
|
| | """
|
| | Tạo hiệu ứng zoom in/out liên tục cho ảnh với FULL FILL tuyệt đối.
|
| | Sử dụng resize thông minh thay vì crop để tránh mất width.
|
| |
|
| | Args:
|
| | clip: ImageClip cần tạo animation
|
| | target_resolution: (width, height) của video đích
|
| | duration: Thời gian hiển thị ảnh
|
| | zoom_type: 'in_out', 'in', 'out', 'random'
|
| |
|
| | Returns:
|
| | VideoClip với hiệu ứng zoom fill tuyệt đối, không mất width
|
| | """
|
| | target_width, target_height = target_resolution
|
| | effective_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).set_duration(duration)
|
| |
|
| | logging.info(f"Zoom Animation - Original: {original_width}x{original_height}")
|
| | logging.info(f"Zoom Animation - Target fill area: {target_width}x{effective_height}")
|
| |
|
| |
|
| | scale_by_width = target_width / original_width
|
| | scale_by_height = effective_height / original_height
|
| |
|
| |
|
| | base_scale_to_fill = max(scale_by_width, scale_by_height)
|
| |
|
| |
|
| | zoom_range = 0.08
|
| |
|
| | if zoom_type == 'random':
|
| | zoom_type = random.choice(['in_out', 'in', 'out'])
|
| |
|
| | logging.info(f"Zoom Animation - Base scale to fill: {base_scale_to_fill:.3f}")
|
| | logging.info(f"Zoom Animation - Zoom range: {zoom_range:.2f}")
|
| | logging.info(f"Zoom Animation - Type: {zoom_type}, Duration: {duration:.1f}s")
|
| |
|
| | def get_zoom_factor(t):
|
| | """Tính zoom factor đảm bảo luôn >= base_scale_to_fill"""
|
| | progress = t / duration
|
| |
|
| | if zoom_type == 'in_out':
|
| |
|
| | factor = base_scale_to_fill * (1 + zoom_range * (0.5 + 0.5 * math.sin(progress * math.pi * 2)))
|
| | elif zoom_type == 'in':
|
| |
|
| | factor = base_scale_to_fill * (1 + zoom_range * (1 - progress ** 0.8))
|
| | elif zoom_type == 'out':
|
| |
|
| | factor = base_scale_to_fill * (1 + zoom_range * (progress ** 0.8))
|
| | else:
|
| | factor = base_scale_to_fill
|
| |
|
| | return factor
|
| |
|
| | def make_frame_at_time(t):
|
| | """Tạo frame tại thời điểm t - SỬ DỤNG RESIZE THÔNG MINH thay vì crop"""
|
| | current_scale = get_zoom_factor(t)
|
| |
|
| |
|
| | scaled_width = int(original_width * current_scale)
|
| | scaled_height = int(original_height * current_scale)
|
| |
|
| |
|
| | scaled_width = scaled_width + (scaled_width % 2)
|
| | scaled_height = scaled_height + (scaled_height % 2)
|
| |
|
| | logging.debug(f"Frame at t={t:.2f}: scale={current_scale:.3f}, size={scaled_width}x{scaled_height}")
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | temp_resized = clip.resize((scaled_width, scaled_height))
|
| |
|
| |
|
| | if scaled_width >= target_width and scaled_height >= effective_height:
|
| |
|
| | crop_x_start = (scaled_width - target_width) // 2
|
| | crop_y_start = (scaled_height - effective_height) // 2
|
| |
|
| | final_clip = temp_resized.crop(
|
| | x1=crop_x_start,
|
| | y1=crop_y_start,
|
| | x2=crop_x_start + target_width,
|
| | y2=crop_y_start + effective_height
|
| | )
|
| | else:
|
| |
|
| |
|
| | final_clip = temp_resized.resize((target_width, effective_height))
|
| |
|
| |
|
| | return final_clip.get_frame(0)
|
| |
|
| |
|
| | animated_clip = clip.set_duration(duration).fl(
|
| | lambda get_frame, t: make_frame_at_time(t),
|
| | apply_to=[]
|
| | )
|
| |
|
| |
|
| | background = ColorClip(
|
| | size=target_resolution,
|
| | color=(0, 0, 0),
|
| | duration=duration
|
| | ).set_fps(24)
|
| |
|
| |
|
| | final_composite = CompositeVideoClip([
|
| | background,
|
| | animated_clip.set_position(("center", "top"))
|
| | ], size=target_resolution)
|
| |
|
| | logging.info(f"Zoom Animation - Hoàn thành với fill tuyệt đối, giữ nguyên width")
|
| |
|
| | return final_composite
|
| |
|
| | def create_transition_effect(clip1, clip2, transition_type='fade', duration=TRANSITION_DURATION):
|
| | """
|
| | Tạo hiệu ứng chuyển cảnh giữa hai clip.
|
| | """
|
| | if not clip1 or not clip2:
|
| | return [clip1, clip2] if clip1 and clip2 else ([clip1] if clip1 else [clip2])
|
| |
|
| |
|
| | if hasattr(clip1, 'size') and hasattr(clip2, 'size'):
|
| | target_size = clip1.size
|
| | if clip2.size != target_size:
|
| | clip2 = clip2.resize(target_size)
|
| |
|
| | if transition_type == 'fade':
|
| |
|
| | clip1_fade_out = clip1.fadeout(duration)
|
| | clip2_fade_in = clip2.fadein(duration)
|
| |
|
| |
|
| | clip1_trimmed = clip1_fade_out.subclip(0, clip1.duration - duration/2)
|
| | clip2_delayed = clip2_fade_in.set_start(clip1.duration - duration/2)
|
| |
|
| | return [clip1_trimmed, clip2_delayed]
|
| |
|
| | elif transition_type in ['slide_left', 'slide_right', 'slide_up', 'slide_down']:
|
| |
|
| | target_size = clip1.size if hasattr(clip1, 'size') else (1080, 1920)
|
| |
|
| |
|
| | if transition_type == 'slide_left':
|
| | start_pos = (target_size[0], 0)
|
| | end_pos = (0, 0)
|
| | elif transition_type == 'slide_right':
|
| | start_pos = (-target_size[0], 0)
|
| | end_pos = (0, 0)
|
| | elif transition_type == 'slide_up':
|
| | start_pos = (0, target_size[1])
|
| | end_pos = (0, 0)
|
| | else:
|
| | start_pos = (0, -target_size[1])
|
| | end_pos = (0, 0)
|
| |
|
| |
|
| | clip2_animated = (clip2
|
| | .set_position(lambda t: (
|
| | start_pos[0] + (end_pos[0] - start_pos[0]) * min(t/duration, 1),
|
| | start_pos[1] + (end_pos[1] - start_pos[1]) * min(t/duration, 1)
|
| | ))
|
| | .set_start(clip1.duration - duration))
|
| |
|
| | clip1_trimmed = clip1.subclip(0, clip1.duration - duration/2)
|
| |
|
| | return [clip1_trimmed, clip2_animated]
|
| |
|
| | elif transition_type == 'zoom':
|
| |
|
| | clip1_zoom_out = clip1.resize(lambda t: 1 - 0.5 * min(t/(clip1.duration), 1)).fadeout(duration)
|
| | clip2_zoom_in = clip2.resize(lambda t: 0.5 + 0.5 * min(t/duration, 1)).fadein(duration)
|
| |
|
| | clip1_trimmed = clip1_zoom_out.subclip(0, clip1.duration - duration/2)
|
| | clip2_delayed = clip2_zoom_in.set_start(clip1.duration - duration/2)
|
| |
|
| | return [clip1_trimmed, clip2_delayed]
|
| |
|
| | else:
|
| |
|
| | return [clip1, clip2]
|
| |
|
| | 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.
|
| | """
|
| | 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,...
|
| | """
|
| | 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).
|
| | """
|
| | 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 + 50
|
| | 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), 35)
|
| |
|
| |
|
| | desc_clip = TextClip(
|
| | description.strip(),
|
| | fontsize=font_size,
|
| | font='Arial-Bold',
|
| | color='white',
|
| | method='caption',
|
| | align='West',
|
| | size=(target_width - 4 * TEXT_MARGIN, None)
|
| | ).set_duration(duration).set_position((2 * TEXT_MARGIN, desc_y))
|
| |
|
| | overlay_clips.append(desc_clip)
|
| | logging.info(f"Description text - Font: Arial-Bold {font_size}px")
|
| |
|
| | 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='Arial-Bold',
|
| | 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.
|
| | LUÔN BẮT ĐẦU BẰNG MỘT ẢNH (nếu có ảnh).
|
| | - Video sẽ được xử lý bằng smart_crop_to_fill (cắt để lấp đầy) - KHÔNG CÓ ZOOM ANIMATION
|
| | - Ảnh sẽ được xử lý bằng create_zoom_animation (zoom animation động, FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH)
|
| | """
|
| | 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_images = []
|
| | valid_videos = []
|
| |
|
| |
|
| | 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
|
| |
|
| | if clip_obj:
|
| |
|
| | 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}")
|
| | media_data = {
|
| | 'clip': clip_obj,
|
| | 'type': media_type,
|
| | 'original_clip': clip_obj,
|
| | 'compatibility_score': compatibility_score,
|
| | 'path': media_path
|
| | }
|
| | if media_type == 'image':
|
| | valid_images.append(media_data)
|
| | else:
|
| | valid_videos.append(media_data)
|
| | 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()
|
| |
|
| |
|
| | valid_images.sort(key=lambda x: x['compatibility_score'], reverse=True)
|
| |
|
| | if not valid_images and not valid_videos:
|
| | logging.warning("Không tìm thấy file media nào hợp lệ sau khi xử lý.")
|
| | return []
|
| |
|
| |
|
| | clips_for_concatenation = []
|
| | total_clip_duration = 0.0
|
| | first_clip_added = False
|
| |
|
| |
|
| | if valid_images:
|
| | first_image_data = valid_images.pop(0)
|
| |
|
| | clip_duration = random.uniform(6.0, 8.0)
|
| | remaining_duration = target_duration - total_clip_duration
|
| | if clip_duration > remaining_duration:
|
| | clip_duration = remaining_duration
|
| |
|
| |
|
| | zoom_type = random.choice(['in_out', 'in', 'out'])
|
| | processed_clip = create_zoom_animation(first_image_data['clip'], resolution, clip_duration, zoom_type)
|
| | clips_for_concatenation.append(processed_clip)
|
| | total_clip_duration += clip_duration
|
| | 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}")
|
| | first_clip_added = True
|
| |
|
| | first_image_data['clip'].close()
|
| |
|
| |
|
| |
|
| | remaining_media = valid_videos + valid_images
|
| | if not remaining_media and not first_clip_added:
|
| | logging.warning("Không có media nào để thêm vào video.")
|
| | return clips_for_concatenation
|
| |
|
| |
|
| | remaining_media.sort(key=lambda x: x['compatibility_score'], reverse=True)
|
| |
|
| |
|
| |
|
| |
|
| | for i, media_data in enumerate(remaining_media):
|
| |
|
| | remaining_duration = target_duration - total_clip_duration
|
| | if remaining_duration <= 0:
|
| | break
|
| |
|
| | clip = media_data['clip']
|
| |
|
| | if media_data['type'] == 'image':
|
| |
|
| | clip_duration = random.uniform(6.0, 8.0)
|
| | else:
|
| |
|
| | clip_duration = getattr(media_data['original_clip'], 'duration', 0)
|
| |
|
| |
|
| | if clip_duration > remaining_duration:
|
| | clip_duration = remaining_duration
|
| |
|
| |
|
| | final_clip = None
|
| | if media_data['type'] == 'video':
|
| |
|
| | processed_clip = smart_crop_to_fill(clip, resolution, media_data['type'], fill_color=(20, 20, 20))
|
| | final_clip = processed_clip.set_duration(clip_duration)
|
| | logging.info(f"Video được xử lý bằng smart_crop_to_fill (KHÔNG CÓ ZOOM ANIMATION): {media_data['path']}")
|
| | else:
|
| |
|
| | zoom_types = ['in_out', 'in', 'out']
|
| | zoom_type = random.choice(zoom_types)
|
| | processed_clip = create_zoom_animation(clip, resolution, clip_duration, zoom_type)
|
| | final_clip = processed_clip
|
| | logging.info(f"Ảnh được xử lý bằng zoom animation FILL + GIỮ WIDTH ({zoom_type}): {media_data['path']}")
|
| |
|
| | if final_clip:
|
| | 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
|
| |
|
| |
|
| | if len(clips_for_concatenation) > 1:
|
| | logging.info("Đang thêm hiệu ứng chuyển cảnh...")
|
| |
|
| | transition_types = ['fade', 'slide_left', 'slide_right']
|
| | clips_with_transitions = []
|
| | for i, clip in enumerate(clips_for_concatenation):
|
| | if i == 0:
|
| |
|
| | clips_with_transitions.append(clip)
|
| | else:
|
| |
|
| | transition_type = random.choice(transition_types)
|
| | prev_clip = clips_with_transitions[-1]
|
| |
|
| | transitioned_clips = create_transition_effect(
|
| | prev_clip, clip,
|
| | transition_type=transition_type,
|
| | duration=TRANSITION_DURATION
|
| | )
|
| |
|
| | if len(transitioned_clips) == 2:
|
| | clips_with_transitions[-1] = transitioned_clips[0]
|
| | clips_with_transitions.append(transitioned_clips[1])
|
| | logging.info(f"Đã thêm transition '{transition_type}' giữa clip {i} và {i+1}")
|
| | else:
|
| | clips_with_transitions.append(clip)
|
| | return clips_with_transitions
|
| | 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).
|
| | Ảnh sẽ có zoom animation động FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH, video giữ nguyên xử lý crop.
|
| | """
|
| | 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)
|
| | THUMBNAIL_SHOW_DURATION = audio_duration
|
| |
|
| | 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=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='ultrafast',
|
| | 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).
|
| | Ảnh sẽ có zoom animation động FILL TUYỆT ĐỐI, GIỮ NGUYÊN WIDTH, các clip sẽ có transitions đẹp.
|
| | """
|
| | 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 = zoom animation FILL + GIỮ WIDTH")
|
| | logging.info("Hiệu ứng: Transitions giữa các clips + thumbnail overlay BOLD")
|
| | logging.info(f"Animation: Zoom range 8%, tốc độ rất chậm, luôn fill + giữ width (trừ {BOTTOM_PADDING}px dưới)")
|
| |
|
| |
|
| | 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 BOLD (trong thumbnail): {description[:50]}...")
|
| | if date: logging.info(f" - Date BOLD (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_b7a03e41-7a82-43fe-b343-e683aeef2c4d.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 = """Ban tổ chức V-League ra tối hậu thư cho Quảng Nam"""
|
| | 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}") |