# full_vertical_video.py import subprocess import random from pathlib import Path from moviepy.editor import VideoFileClip from pydub import AudioSegment from PIL import Image, ImageDraw class FullVerticalVideoGenerator: def __init__(self, source_folder, temp_folder, preview_generator, vertical_resolution=(1080, 1920), fade_duration=1, gradients_folder=None): """ Ініціалізація генератора повних вертикальних відео Args: source_folder: Папка з фоновими відео temp_folder: Папка для тимчасових файлів preview_generator: Об'єкт PreviewGenerator для створення превью vertical_resolution: Розмір вертикального відео (ширина, висота) fade_duration: Тривалість fade ефектів gradients_folder: Папка з градієнтами (JPG зображення) """ self.source_folder = Path(source_folder) self.temp_folder = Path(temp_folder) self.preview_generator = preview_generator self.vertical_resolution = vertical_resolution # (1080, 1920) - 9:16 self.final_resolution = (1920, 1080) # Фінальний розмір з градієнтним фоном - 16:9 self.fade_duration = fade_duration self.gradients_folder = Path(gradients_folder) if gradients_folder else None # Перевіряємо наявність папки з градієнтами if self.gradients_folder and not self.gradients_folder.exists(): print(f"⚠️ Папка з градієнтами {self.gradients_folder} не існує!") self.gradients_folder = None def get_random_gradient(self): """ Вибирає випадковий градієнт з папки Returns: Path: Шлях до випадкового градієнту або None якщо папка порожня """ if not self.gradients_folder: return None # Шукаємо всі зображення в папці градієнтів gradient_files = ( list(self.gradients_folder.glob("*.jpg")) + list(self.gradients_folder.glob("*.jpeg")) + list(self.gradients_folder.glob("*.png")) + list(self.gradients_folder.glob("*.JPG")) + list(self.gradients_folder.glob("*.JPEG")) + list(self.gradients_folder.glob("*.PNG")) ) if not gradient_files: print(f"⚠️ У папці {self.gradients_folder} не знайдено градієнтів (JPG/PNG)!") return None # Вибираємо випадковий градієнт selected_gradient = random.choice(gradient_files) print(f"???? Вибрано градієнт: {selected_gradient.name}") return selected_gradient def create_gradient_background(self, story_id, duration): """ Створює градієнтний фон для розміщення вертикального відео ОНОВЛЕНО: Використовує випадковий градієнт з папки замість генерації Args: story_id: ID історії duration: Тривалість відео в секундах Returns: Path: Шлях до створеного градієнтного відео """ gradient_path = self.temp_folder / f"{story_id}_gradient_bg.mp4" # Спробуємо взяти випадковий градієнт з папки gradient_image = self.get_random_gradient() if gradient_image: # Використовуємо градієнт з папки print(f"???? Використовуємо градієнт з файлу: {gradient_image.name}") try: # Масштабуємо градієнт до потрібного розміру temp_gradient_path = self.temp_folder / f"{story_id}_scaled_gradient.png" # Використовуємо PNG замість JPEG # Завантажуємо та масштабуємо градієнт with Image.open(gradient_image) as img: # Конвертуємо в RGB якщо потрібно, але зберігаємо максимальну якість if img.mode in ('RGBA', 'LA', 'P'): # Створюємо чорний фон замість білого для градієнтів rgb_img = Image.new('RGB', img.size, (0, 0, 0)) if img.mode == 'P': img = img.convert('RGBA') rgb_img.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None) img = rgb_img elif img.mode != 'RGB': img = img.convert('RGB') # Масштабуємо до фінального розміру з високою якістю scaled_img = img.resize(self.final_resolution, Image.Resampling.LANCZOS) # Зберігаємо як PNG без втрат якості scaled_img.save(temp_gradient_path, "PNG") # Створюємо відео з високим бітрейтом та без додаткового стиснення subprocess.run([ "ffmpeg", "-y", "-loop", "1", "-i", str(temp_gradient_path), "-t", str(duration), "-c:v", "h264_nvenc", "-preset", "medium", # ЗБАЛАНСОВАНИЙ: було "slow" "-b:v", "10M", # ЗБАЛАНСОВАНИЙ: було "15M" "-crf", "18", # ЗБАЛАНСОВАНИЙ: було "16" "-pix_fmt", "yuv420p", "-r", "30", str(gradient_path) ], check=True) # Видаляємо тимчасовий файл temp_gradient_path.unlink(missing_ok=True) except Exception as e: print(f"❌ Помилка при обробці градієнту {gradient_image.name}: {e}") print("???? Використовуємо програмний градієнт замість файлового") gradient_image = None # Перемикаємося на fallback if not gradient_image: # Fallback: створюємо програмний градієнт як було раніше print(f"???? Створюємо програмний градієнт (fallback)") gradient_img_path = self.temp_folder / f"{story_id}_gradient.png" img = Image.new('RGB', self.final_resolution, color='black') draw = ImageDraw.Draw(img) # Створюємо вертикальний градієнт від темно-синього до чорного for y in range(self.final_resolution[1]): # Градієнт від темно-синього (30, 30, 80) до чорного (0, 0, 0) progress = y / self.final_resolution[1] r = int(30 * (1 - progress)) g = int(30 * (1 - progress)) b = int(80 * (1 - progress)) color = (r, g, b) draw.line([(0, y), (self.final_resolution[0], y)], fill=color) img.save(gradient_img_path) # Створюємо відео з градієнтного зображення з високою якістю subprocess.run([ "ffmpeg", "-y", "-loop", "1", "-i", str(gradient_img_path), "-t", str(duration), "-c:v", "h264_nvenc", "-preset", "medium", # ЗБАЛАНСОВАНИЙ: було "slow" "-b:v", "10M", # ЗБАЛАНСОВАНИЙ: було "15M" "-crf", "18", # ЗБАЛАНСОВАНИЙ: було "16" "-pix_fmt", "yuv420p", "-r", "30", str(gradient_path) ], check=True) # Видаляємо тимчасовий файл gradient_img_path.unlink(missing_ok=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 create_preview_video_segments_vertical(self, story_id, preview_duration): """ Створює СПРАВДІ вертикальні фонові відео сегменти для preview частини Args: story_id: ID історії для іменування файлів preview_duration: Тривалість preview в секундах Returns: tuple: (шлях до об'єднаного 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}_full_v_preview_bg_{len(preview_clips):02d}.mp4" # Створюємо СПРАВДІ вертикальний кліп (9:16) subprocess.run([ "ffmpeg", "-y", "-i", str(bg_video), "-ss", "0", "-t", str(use_duration), "-vf", f"scale='max({self.vertical_resolution[0]},iw*{self.vertical_resolution[1]}/ih)':'max({self.vertical_resolution[1]},ih*{self.vertical_resolution[0]}/iw)',crop={self.vertical_resolution[0]}:{self.vertical_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 # Об'єднуємо всі preview кліпи if len(preview_clips) > 1: preview_concat_list = self.temp_folder / f"{story_id}_full_v_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}_full_v_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] return preview_combined, bg_iter def create_preview_with_overlay_vertical(self, story_id, preview_combined, horizontal_preview_path, preview_duration): """ Накладає вертикальне preview зображення на фонове відео Args: story_id: ID історії preview_combined: Шлях до об'єднаного фонового відео horizontal_preview_path: Шлях до горизонтального preview зображення preview_duration: Тривалість preview Returns: Path: Шлях до preview відео з накладеним зображенням """ # Створюємо вертикальне preview зображення preview_img_path = self.temp_folder / f"{story_id}_preview_full_vertical.png" self.preview_generator.draw_vertical_preview(horizontal_preview_path, preview_img_path) # Накладаємо preview зображення на відео preview_video = self.temp_folder / f"{story_id}_full_v_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_video_segments_vertical(self, story_id, bg_iter, target_duration, duration_collected): """ Створює СПРАВДІ вертикальні відео сегменти для основної частини історії Args: story_id: ID історії bg_iter: Ітератор фонових відео target_duration: Цільова тривалість всього відео duration_collected: Вже зібрана тривалість Returns: list: Список шляхів до створених відео сегментів """ used_clips = [] # Продовжуємо з того місця де зупинились в bg_iter для story частини for idx, bg in enumerate(bg_iter): use_dur = target_duration - duration_collected if use_dur <= 0: break clip = VideoFileClip(str(bg)) use_dur = min(clip.duration, use_dur) clip.close() if use_dur <= 0: break temp_out = self.temp_folder / f"full_v_clip_{idx:02d}.mp4" # Створюємо СПРАВДІ вертикальний відеокліп (9:16) base_filters = f"scale='max({self.vertical_resolution[0]},iw*{self.vertical_resolution[1]}/ih)':'max({self.vertical_resolution[1]},ih*{self.vertical_resolution[0]}/iw)',crop={self.vertical_resolution[0]}:{self.vertical_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) duration_collected += use_dur if duration_collected >= target_duration: break # Якщо все ще не достатньо відео, додаємо ще if duration_collected < target_duration: bg_videos_extra = list(self.source_folder.glob("*.mp4")) random.shuffle(bg_videos_extra) for idx, bg in enumerate(bg_videos_extra): if duration_collected >= target_duration: break clip = VideoFileClip(str(bg)) use_dur = min(clip.duration, target_duration - duration_collected) clip.close() if use_dur <= 0: break temp_out = self.temp_folder / f"full_v_clip_extra_{idx:02d}.mp4" # Створюємо СПРАВДІ вертикальний відеокліп base_filters = f"scale='max({self.vertical_resolution[0]},iw*{self.vertical_resolution[1]}/ih)':'max({self.vertical_resolution[1]},ih*{self.vertical_resolution[0]}/iw)',crop={self.vertical_resolution[0]}:{self.vertical_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) duration_collected += use_dur return used_clips def combine_video_segments_vertical(self, story_id, preview_video, story_clips): """ Об'єднує всі вертикальні відео сегменти в одне вертикальне відео Args: story_id: ID історії preview_video: Шлях до preview відео story_clips: Список шляхів до story кліпів Returns: Path: Шлях до об'єднаного ВЕРТИКАЛЬНОГО відео (9:16) """ all_clips = [preview_video] + story_clips concat_list = self.temp_folder / f"{story_id}_full_v_concat.txt" with open(concat_list, "w", encoding="utf-8") as f: for clip in all_clips: f.write(f"file '{clip.as_posix()}'\n") # Об'єднуємо в ВЕРТИКАЛЬНЕ відео vertical_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", "5M", "-preset", "p4", str(vertical_combined_path) ], check=True) return vertical_combined_path def place_vertical_on_gradient(self, story_id, vertical_video, gradient_bg): """ Розміщує вертикальне відео (9:16) на градієнтному фоні (16:9) БЕЗ ОБРІЗАННЯ Вертикальне відео масштабується щоб поміститися по висоті, але зберігає пропорції Args: story_id: ID історії vertical_video: Шлях до вертикального відео gradient_bg: Шлях до градієнтного фону Returns: Path: Шлях до фінального горизонтального відео з вертикальним відео по центру """ final_video_path = self.temp_folder / f"{story_id}_final_with_gradient.mp4" # ВИПРАВЛЕНО: Масштабуємо вертикальне відео по висоті, зберігаючи пропорції # Фон: 1920x1080, вертикальне відео: 1080x1920 # Масштабуємо вертикальне відео так, щоб його висота = висоті фону (1080) # При цьому ширина стане: 1080 * (1080/1920) = 607 пікселів subprocess.run([ "ffmpeg", "-y", "-i", str(gradient_bg), "-i", str(vertical_video), "-filter_complex", "[1:v]scale=-1:1080[scaled_vertical];[0:v][scaled_vertical]overlay=(W-w)/2:(H-h)/2", "-c:v", "h264_nvenc", "-preset", "medium", # ЗБАЛАНСОВАНИЙ: було "slow" "-b:v", "12M", # ЗБАЛАНСОВАНИЙ: було "20M" "-crf", "18", # ЗБАЛАНСОВАНИЙ: було "15" "-pix_fmt", "yuv420p", "-shortest", str(final_video_path) ], check=True) return final_video_path def create_full_vertical_video(self, story_id, preview_txt, audio_preview, audio_story, horizontal_preview_path): """ Створює повне вертикальне відео з градієнтним фоном НОВА ЛОГІКА: 1. Створює вертикальне відео (9:16) 2. Створює градієнтний фон (16:9) - ТЕПЕР З ПАПКИ! 3. Розміщує вертикальне відео по центру градієнтного фону Args: story_id: ID історії preview_txt: Текст preview audio_preview: AudioSegment з preview аудіо audio_story: AudioSegment з story аудіо horizontal_preview_path: Шлях до горизонтального preview зображення Returns: Path: Шлях до створеного фінального відео (16:9 з вертикальним відео по центру) """ print(f"???? Створення вертикального відео (9:16)...") preview_duration = audio_preview.duration_seconds final_audio = audio_preview + AudioSegment.silent(duration=500) + audio_story total_duration = final_audio.duration_seconds # Етап 1: Створюємо градієнтний фон (ТЕПЕР З ПАПКИ!) print(f"???? Створення градієнтного фону...") gradient_bg = self.create_gradient_background(story_id, total_duration) # Етап 2: Створюємо фонові сегменти для preview (ВЕРТИКАЛЬНІ!) preview_combined, bg_iter = self.create_preview_video_segments_vertical(story_id, preview_duration) # Етап 3: Накладаємо preview зображення preview_video = self.create_preview_with_overlay_vertical( story_id, preview_combined, horizontal_preview_path, preview_duration ) print(f"???? Створення основного вертикального відео...") # Етап 4: Створюємо story сегменти (ВЕРТИКАЛЬНІ!) story_clips = self.create_story_video_segments_vertical( story_id, bg_iter, total_duration, preview_duration ) # Етап 5: Об'єднуємо всі вертикальні сегменти vertical_combined = self.combine_video_segments_vertical(story_id, preview_video, story_clips) print(f"???? Розміщення вертикального відео на градієнтному фоні...") # Етап 6: Розміщуємо вертикальне відео на градієнтному фоні final_video = self.place_vertical_on_gradient(story_id, vertical_combined, gradient_bg) return final_video def finalize_full_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)