# vertical_video.py - модифікована версія import subprocess import random import re from pathlib import Path from moviepy.editor import VideoFileClip from pydub import AudioSegment class VerticalVideoGenerator: def __init__(self, source_folder, temp_folder, preview_generator, shorts_resolution=(1080, 1920), target_duration=59): """ Ініціалізація генератора вертикальних відео (Shorts) Args: source_folder: Папка з фоновими відео temp_folder: Папка для тимчасових файлів preview_generator: Об'єкт PreviewGenerator для створення превью shorts_resolution: Розмір Shorts відео (ширина, висота) target_duration: Цільова тривалість Shorts у секундах """ 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 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 - 5: # залишаємо 5 секунд на кінцівку break truncated_text += sentence + ". " current_duration += sentence_duration return truncated_text.strip() 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_tmp = self.temp_folder / f"{story_id}_shorts_preview_bg_{len(preview_clips):02d}.mp4" # Створюємо вертикальний кліп з потрібною тривалістю subprocess.run([ "ffmpeg", "-y", "-i", str(bg_video), "-ss", "0", "-t", str(use_duration), "-vf", f"scale='max({self.shorts_resolution[0]},iw*{self.shorts_resolution[1]}/ih)':'max({self.shorts_resolution[1]},ih*{self.shorts_resolution[0]}/iw)',crop={self.shorts_resolution[0]}:{self.shorts_resolution[1]},fps=30", "-an", "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "5M", str(preview_clip_tmp) ], check=True) preview_clips.append(preview_clip_tmp) 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}_shorts_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}_shorts_preview_combined.mp4" subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(preview_concat_list), "-c:v", "h264_nvenc", "-b:v", "5M", "-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}_shorts_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", "5M", 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 for idx, bg in enumerate(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 temp_out = self.temp_folder / f"shorts_clip_{idx:02d}.mp4" base_filters = f"scale='max({self.shorts_resolution[0]},iw*{self.shorts_resolution[1]}/ih)':'max({self.shorts_resolution[1]},ih*{self.shorts_resolution[0]}/iw)',crop={self.shorts_resolution[0]}:{self.shorts_resolution[1]},fps=30" filter_string = self.apply_safe_fade_filters(use_dur, base_filters) subprocess.run([ "ffmpeg", "-y", "-i", str(bg), "-ss", "0", "-t", str(use_dur), "-vf", filter_string, "-an", "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "5M", str(temp_out) ], check=True) used_clips.append(temp_out) remaining_story_duration -= use_dur # Якщо все ще не достатньо відео для story, додаємо ще if remaining_story_duration > 0: bg_videos_extra = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos_extra) for idx, bg in enumerate(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 temp_out = self.temp_folder / f"shorts_clip_extra_{idx:02d}.mp4" base_filters = f"scale='max({self.shorts_resolution[0]},iw*{self.shorts_resolution[1]}/ih)':'max({self.shorts_resolution[1]},ih*{self.shorts_resolution[0]}/iw)',crop={self.shorts_resolution[0]}:{self.shorts_resolution[1]},fps=30" filter_string = self.apply_safe_fade_filters(use_dur, base_filters) subprocess.run([ "ffmpeg", "-y", "-i", str(bg), "-ss", "0", "-t", str(use_dur), "-vf", filter_string, "-an", "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "5M", str(temp_out) ], check=True) used_clips.append(temp_out) remaining_story_duration -= use_dur return used_clips def create_ending_segment(self, story_id, bg_iter, ending_duration): """Створює сегмент кінцівки з фоновим відео""" ending_bg = self.temp_folder / f"{story_id}_ending_bg.mp4" # Беремо наступне фонове відео для кінцівки try: ending_bg_source = next(bg_iter) except StopIteration: # Якщо відео закінчились, беремо перше знову bg_videos_reset = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos_reset) ending_bg_source = bg_videos_reset[0] # Створюємо фонове відео для кінцівки з правильним масштабуванням subprocess.run([ "ffmpeg", "-y", "-i", str(ending_bg_source), "-ss", "0", "-t", str(ending_duration), "-vf", f"scale='max({self.shorts_resolution[0]},iw*{self.shorts_resolution[1]}/ih)':'max({self.shorts_resolution[1]},ih*{self.shorts_resolution[0]}/iw)',crop={self.shorts_resolution[0]}:{self.shorts_resolution[1]},fps=30", "-an", "-c:v", "h264_nvenc", "-preset", "p4", "-b:v", "5M", str(ending_bg) ], check=True) return ending_bg def combine_shorts_segments(self, story_id, preview_video, story_clips, ending_clip=None): """Об'єднує всі сегменти Shorts відео""" if ending_clip: used_clips = [preview_video] + story_clips + [ending_clip] else: used_clips = [preview_video] + story_clips concat_list = self.temp_folder / f"{story_id}_shorts_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}_shorts_combined.mp4" subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c:v", "h264_nvenc", "-b:v", "5M", "-preset", "p4", str(combined_path) ], check=True) return combined_path def create_shorts_video(self, story_txt, preview_txt, story_id, horizontal_preview_path, coqui_tts): """ Створює вертикальне відео для Shorts (ОБРІЗАНЕ з кінцівкою) Args: story_txt: Текст історії preview_txt: Текст preview story_id: ID історії horizontal_preview_path: Шлях до горизонтального preview coqui_tts: Функція TTS для генерації аудіо Returns: Path: Шлях до створеного Shorts відео """ print(f"???? Створення Shorts відео...") # Генеруємо аудіо 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) # Створюємо кінцівку англійською з паузою ПЕРЕД нею ending_text = "Full video on the channel. Subscribe for more stories" audio_ending = coqui_tts(ending_text) # Додаємо паузу ПЕРЕД кінцівкою (0.5 сек) та в кінці (1 сек) audio_ending = AudioSegment.silent(duration=500) + audio_ending + AudioSegment.silent(duration=1000) # Збираємо фінальне аудіо final_audio = audio_preview + AudioSegment.silent(duration=500) + audio_story + audio_ending audio_path = self.temp_folder / f"{story_id}_shorts_voice.wav" final_audio.export(audio_path, format="wav") # Створюємо preview сегменти preview_duration = audio_preview.duration_seconds story_duration = audio_story.duration_seconds ending_duration = audio_ending.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) # Створюємо кінцівку ending_clip = self.create_ending_segment(story_id, bg_iter, ending_duration) # Об'єднуємо всі сегменти combined_video = self.combine_shorts_segments(story_id, preview_video, story_clips, ending_clip) return combined_video, audio_path def create_full_vertical_video(self, story_txt, preview_txt, story_id, horizontal_preview_path, coqui_tts): """ Створює повне вертикальне відео БЕЗ обрізання та БЕЗ кінцівки про канал Args: story_txt: Повний текст історії preview_txt: Текст preview story_id: ID історії horizontal_preview_path: Шлях до горизонтального preview coqui_tts: Функція TTS для генерації аудіо Returns: tuple: (Path до відео, Path до аудіо) """ print(f"???? Створення повного вертикального відео...") # Генеруємо аудіо для ПОВНОЇ історії (без обрізання) audio_preview = coqui_tts(preview_txt) audio_story = coqui_tts(story_txt) # Використовуємо повний текст # НЕ додаємо кінцівку про канал - тільки preview + пауза + повна історія final_audio = audio_preview + AudioSegment.silent(duration=500) + audio_story audio_path = self.temp_folder / f"{story_id}_full_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_shorts_segments(story_id, preview_video, story_clips, ending_clip=None) print(f"✅ Повне вертикальне відео створено без обрізання") print(f"⏱️ Тривалість: {final_audio.duration_seconds:.1f} секунд") return combined_video, audio_path def finalize_shorts_video(self, combined_video, audio_path, output_path): """ Фінальна збірка Shorts відео з аудіо 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)