import subprocess import random import re from pathlib import Path from moviepy.editor import VideoFileClip from pydub import AudioSegment class EnhancedVerticalVideoGenerator: def __init__(self, source_folder, temp_folder, preview_generator, vertical_resolution=(1920, 1080), target_duration=59): """ Ініціалізація генератора вертикальних відео з покращеним форматом Args: source_folder: Папка з фоновими відео temp_folder: Папка для тимчасових файлів preview_generator: Об'єкт PreviewGenerator для створення превью vertical_resolution: Розмір вертикального відео (ширина, висота) - 1920x1080 target_duration: Цільова тривалість відео у секундах """ self.source_folder = Path(source_folder) self.temp_folder = Path(temp_folder) self.preview_generator = preview_generator self.vertical_resolution = vertical_resolution self.target_duration = target_duration def create_gradient_background(self, story_id, width, height, duration): """ Створює приємний градієнт фон для заповнення чорних областей """ gradient_path = self.temp_folder / f"{story_id}_gradient.mp4" # Створюємо градієнт від темно-синього до фіолетового gradient_colors = [ "#1a1a2e", # темно-синій "#16213e", # синій "#0f4c75", # середній синій "#3282b8", # світліший синій "#533483" # фіолетовий ] # Створюємо градієнт фон gradient_filter = f"color=c=#1a1a2e:size={width}x{height}:duration={duration}:rate=30" gradient_overlay = f"geq=r='128+64*sin((X+Y)*0.01+T*0.5)':g='64+32*sin((X-Y)*0.01+T*0.3)':b='128+96*sin((X*Y)*0.001+T*0.2)'" subprocess.run([ "ffmpeg", "-y", "-f", "lavfi", "-i", gradient_filter, "-vf", gradient_overlay, "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "3M", str(gradient_path) ], check=True) return gradient_path def apply_safe_fade_filters(self, use_dur, base_filters): """Застосовує fade фільтри безпечно для вертикального відео""" fade_in_duration = min(0.3, use_dur * 0.3) fade_out_duration = min(0.3, use_dur * 0.3) if fade_in_duration + fade_out_duration > use_dur: fade_in_duration = use_dur * 0.4 fade_out_duration = use_dur * 0.4 fade_out_start = max(0, use_dur - fade_out_duration) fade_filters = [] if fade_in_duration > 0.05: fade_filters.append(f"fade=t=in:st=0:d={fade_in_duration}") if fade_out_duration > 0.05 and fade_out_start > fade_in_duration: fade_filters.append(f"fade=t=out:st={fade_out_start}:d={fade_out_duration}") all_filters = [base_filters] + fade_filters return ",".join(all_filters) def truncate_story_for_duration(self, story_text, target_duration, preview_duration): """Обрізає історію до цільової тривалості, завершуючи на кінці речення""" sentences = re.split(r'[.!?]+', story_text) sentences = [s.strip() for s in sentences if s.strip()] truncated_text = "" current_duration = preview_duration + 0.5 # preview + пауза for sentence in sentences: words_count = len(sentence.split()) sentence_duration = words_count / 2.5 # приблизно 2.5 слова в секунду if current_duration + sentence_duration > target_duration: break truncated_text += sentence + ". " current_duration += sentence_duration return truncated_text.strip() def create_vertical_video_segment(self, bg_video_path, duration, story_id, segment_idx): """ Створює вертикальний відео сегмент з градієнтом замість чорних областей """ temp_segment = self.temp_folder / f"{story_id}_vertical_segment_{segment_idx:02d}.mp4" # Створюємо градієнт фон gradient_bg = self.create_gradient_background(story_id + f"_seg{segment_idx}", self.vertical_resolution[0], self.vertical_resolution[1], duration) # Масштабуємо оригінальне відео щоб воно поміщалось в центр # Оригінальне відео буде в центрі, а градієнт буде по краях video_scale = f"scale='min({self.vertical_resolution[0]},iw)':'min({self.vertical_resolution[1]},ih)':force_original_aspect_ratio=decrease" # Комбінуємо градієнт і відео filter_complex = f""" [0:v]{video_scale}[scaled]; [1:v][scaled]overlay=(W-w)/2:(H-h)/2:format=auto[final]; [final]fps=30 """ subprocess.run([ "ffmpeg", "-y", "-i", str(bg_video_path), "-i", str(gradient_bg), "-filter_complex", filter_complex.strip(), "-t", str(duration), "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "8M", str(temp_segment) ], check=True) return temp_segment def create_preview_segments_vertical(self, story_id, preview_duration): """Створює вертикальні фонові сегменти для preview""" bg_videos = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos) bg_iter = iter(bg_videos) preview_clips = [] preview_duration_collected = 0 while preview_duration_collected < preview_duration: try: bg_video = next(bg_iter) except StopIteration: bg_videos_reset = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos_reset) bg_iter = iter(bg_videos_reset) bg_video = next(bg_iter) clip = VideoFileClip(str(bg_video)) remaining_preview_duration = preview_duration - preview_duration_collected use_duration = min(clip.duration, remaining_preview_duration) clip.close() if use_duration <= 0: break preview_clip = self.create_vertical_video_segment( bg_video, use_duration, story_id, len(preview_clips) ) preview_clips.append(preview_clip) preview_duration_collected += use_duration return preview_clips, bg_iter def create_preview_with_overlay_vertical(self, story_id, preview_clips, horizontal_preview_path, preview_duration): """Створює preview відео з накладеним вертикальним preview зображенням""" # Об'єднуємо всі preview кліпи if len(preview_clips) > 1: preview_concat_list = self.temp_folder / f"{story_id}_vertical_preview_concat.txt" with open(preview_concat_list, "w", encoding="utf-8") as f: for clip in preview_clips: f.write(f"file '{clip.as_posix()}'\n") preview_combined = self.temp_folder / f"{story_id}_vertical_preview_combined.mp4" subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(preview_concat_list), "-c:v", "h264_nvenc", "-b:v", "8M", "-preset", "p4", str(preview_combined) ], check=True) else: preview_combined = preview_clips[0] # Створюємо вертикальне preview зображення preview_img_path = self.temp_folder / f"{story_id}_preview_vertical.png" self.preview_generator.draw_vertical_preview(horizontal_preview_path, preview_img_path) # Накладаємо preview зображення на відео preview_video = self.temp_folder / f"{story_id}_vertical_preview_video.mp4" subprocess.run([ "ffmpeg", "-y", "-i", str(preview_combined), "-i", str(preview_img_path), "-filter_complex", "[1:v]format=rgba[overlay];[0:v][overlay]overlay=(W-w)/2:(H-h)/2:format=auto", "-t", str(preview_duration), "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "8M", str(preview_video) ], check=True) return preview_video def create_story_segments_vertical(self, story_id, bg_iter, story_duration): """Створює вертикальні відео сегменти для основної частини історії""" used_clips = [] remaining_story_duration = story_duration segment_idx = 0 for bg in bg_iter: if remaining_story_duration <= 0: break clip = VideoFileClip(str(bg)) use_dur = min(clip.duration, remaining_story_duration) clip.close() if use_dur <= 0: break story_segment = self.create_vertical_video_segment( bg, use_dur, story_id, segment_idx + 100 # +100 щоб не перетинатись з preview ) used_clips.append(story_segment) remaining_story_duration -= use_dur segment_idx += 1 # Якщо все ще не достатньо відео для story, додаємо ще if remaining_story_duration > 0: bg_videos_extra = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos_extra) for bg in bg_videos_extra: if remaining_story_duration <= 0: break clip = VideoFileClip(str(bg)) use_dur = min(clip.duration, remaining_story_duration) clip.close() if use_dur <= 0: break story_segment = self.create_vertical_video_segment( bg, use_dur, story_id, segment_idx + 100 ) used_clips.append(story_segment) remaining_story_duration -= use_dur segment_idx += 1 return used_clips def combine_vertical_segments(self, story_id, preview_video, story_clips): """Об'єднує всі сегменти вертикального відео (БЕЗ кінцівки про підписку)""" used_clips = [preview_video] + story_clips concat_list = self.temp_folder / f"{story_id}_vertical_concat.txt" with open(concat_list, "w", encoding="utf-8") as f: for clip in used_clips: f.write(f"file '{clip.as_posix()}'\n") combined_path = self.temp_folder / f"{story_id}_vertical_combined.mp4" subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:v", "h264_nvenc", "-b:v", "8M", "-preset", "p4", str(combined_path) ], check=True) return combined_path def create_vertical_video(self, story_txt, preview_txt, story_id, horizontal_preview_path, coqui_tts): """ Створює вертикальне відео (1920x1080) БЕЗ заклику підписки Args: story_txt: Текст історії preview_txt: Текст preview story_id: ID історії horizontal_preview_path: Шлях до горизонтального preview coqui_tts: Функція TTS для генерації аудіо Returns: tuple: (Path до відео, Path до аудіо) """ print(f"???? Створення вертикального відео (1920x1080)...") # Генеруємо аудіо audio_preview = coqui_tts(preview_txt) # Обрізаємо історію для цільової тривалості truncated_story = self.truncate_story_for_duration( story_txt, self.target_duration, audio_preview.duration_seconds ) audio_story = coqui_tts(truncated_story) # Збираємо фінальне аудіо БЕЗ кінцівки про підписку final_audio = audio_preview + AudioSegment.silent(duration=500) + audio_story audio_path = self.temp_folder / f"{story_id}_vertical_voice.wav" final_audio.export(audio_path, format="wav") # Створюємо preview сегменти preview_duration = audio_preview.duration_seconds story_duration = audio_story.duration_seconds preview_clips, bg_iter = self.create_preview_segments_vertical(story_id, preview_duration) preview_video = self.create_preview_with_overlay_vertical( story_id, preview_clips, horizontal_preview_path, preview_duration ) # Створюємо story сегменти story_clips = self.create_story_segments_vertical(story_id, bg_iter, story_duration) # Об'єднуємо всі сегменти (БЕЗ кінцівки) combined_video = self.combine_vertical_segments(story_id, preview_video, story_clips) return combined_video, audio_path def finalize_vertical_video(self, combined_video, audio_path, output_path): """ Фінальна збірка вертикального відео з аудіо Args: combined_video: Шлях до відео без аудіо audio_path: Шлях до аудіо файлу output_path: Шлях для збереження фінального відео """ print(f"???? Фінальна збірка вертикального відео...") subprocess.run([ "ffmpeg", "-y", "-i", str(combined_video), "-i", str(audio_path), "-c:v", "copy", "-c:a", "aac", "-shortest", "-movflags", "+faststart", str(output_path) ], check=True) class ShortsVideoGenerator: """Генератор для коротких відео (Shorts) з покращеннями""" def __init__(self, source_folder, temp_folder, preview_generator, shorts_resolution=(1080, 1920), target_duration=59): self.source_folder = Path(source_folder) self.temp_folder = Path(temp_folder) self.preview_generator = preview_generator self.shorts_resolution = shorts_resolution self.target_duration = target_duration def create_gradient_background_shorts(self, story_id, width, height, duration): """Створює приємний градієнт фон для Shorts""" gradient_path = self.temp_folder / f"{story_id}_shorts_gradient.mp4" # Створюємо вертикальний градієнт для Shorts gradient_filter = f"color=c=#0f0f23:size={width}x{height}:duration={duration}:rate=30" gradient_overlay = f"geq=r='96+48*sin((Y)*0.003+T*0.4)':g='48+24*sin((Y)*0.002+T*0.2)':b='96+72*sin((Y)*0.004+T*0.6)'" subprocess.run([ "ffmpeg", "-y", "-f", "lavfi", "-i", gradient_filter, "-vf", gradient_overlay, "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "5M", str(gradient_path) ], check=True) return gradient_path def create_shorts_video_segment(self, bg_video_path, duration, story_id, segment_idx): """Створює сегмент відео для Shorts з градієнтом""" temp_segment = self.temp_folder / f"{story_id}_shorts_segment_{segment_idx:02d}.mp4" # Створюємо градієнт фон gradient_bg = self.create_gradient_background_shorts( story_id + f"_shorts_seg{segment_idx}", self.shorts_resolution[0], self.shorts_resolution[1], duration ) # Масштабуємо відео для Shorts формату video_scale = f"scale='min({self.shorts_resolution[0]},iw)':'min({self.shorts_resolution[1]},ih)':force_original_aspect_ratio=decrease" filter_complex = f""" [0:v]{video_scale}[scaled]; [1:v][scaled]overlay=(W-w)/2:(H-h)/2:format=auto[final]; [final]fps=30 """ subprocess.run([ "ffmpeg", "-y", "-i", str(bg_video_path), "-i", str(gradient_bg), "-filter_complex", filter_complex.strip(), "-t", str(duration), "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "8M", str(temp_segment) ], check=True) return temp_segment def truncate_story_for_duration(self, story_text, target_duration, preview_duration): """Обрізає історію до цільової тривалості для Shorts""" sentences = re.split(r'[.!?]+', story_text) sentences = [s.strip() for s in sentences if s.strip()] truncated_text = "" current_duration = preview_duration + 0.5 for sentence in sentences: words_count = len(sentence.split()) sentence_duration = words_count / 2.5 if current_duration + sentence_duration > target_duration: break truncated_text += sentence + ". " current_duration += sentence_duration return truncated_text.strip() def create_shorts_video(self, story_txt, preview_txt, story_id, horizontal_preview_path, coqui_tts): """ Створює Shorts відео БЕЗ заклику підписки в кінці """ print(f"???? Створення Shorts відео (БЕЗ заклику підписки)...") # Генеруємо аудіо audio_preview = coqui_tts(preview_txt) # Обрізаємо історію для Shorts truncated_story = self.truncate_story_for_duration( story_txt, self.target_duration, audio_preview.duration_seconds ) audio_story = coqui_tts(truncated_story) # БЕЗ кінцівки про підписку final_audio = audio_preview + AudioSegment.silent(duration=500) + audio_story audio_path = self.temp_folder / f"{story_id}_shorts_voice.wav" final_audio.export(audio_path, format="wav") # Створюємо відео сегменти bg_videos = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos) clips = [] remaining_duration = final_audio.duration_seconds segment_idx = 0 for bg_video in bg_videos: if remaining_duration <= 0: break clip = VideoFileClip(str(bg_video)) use_duration = min(clip.duration, remaining_duration) clip.close() if use_duration <= 0: break shorts_segment = self.create_shorts_video_segment( bg_video, use_duration, story_id, segment_idx ) clips.append(shorts_segment) remaining_duration -= use_duration segment_idx += 1 # Об'єднуємо сегменти concat_list = self.temp_folder / f"{story_id}_shorts_concat.txt" with open(concat_list, "w", encoding="utf-8") as f: for clip in clips: f.write(f"file '{clip.as_posix()}'\n") combined_video = self.temp_folder / f"{story_id}_shorts_combined.mp4" subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:v", "h264_nvenc", "-b:v", "8M", "-preset", "p4", str(combined_video) ], check=True) return combined_video, audio_path def finalize_shorts_video(self, combined_video, audio_path, output_path): """Фінальна збірка Shorts відео""" print(f"???? Фінальна збірка Shorts відео...") subprocess.run([ "ffmpeg", "-y", "-i", str(combined_video), "-i", str(audio_path), "-c:v", "copy", "-c:a", "aac", "-shortest", "-movflags", "+faststart", str(output_path) ], check=True)