from __future__ import annotations import math import uuid import moviepy.editor as mpe import numpy as np from PIL import Image, ImageDraw, ImageFont, ImageColor # ---------------------------- # Шрыфты # ---------------------------- AVAILABLE_FONTS = { "DejaVuSans-Bold": "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "DejaVuSans": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "LiberationSerif-Bold": "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf", "BadScript-Regular": "fonts/Bad_Script/BadScript-Regular.ttf", "Gidole-Regular": "fonts/Gidole/Gidole-Regular.ttf", "GreatVibes-Regular": "fonts/Great_Vibes/GreatVibes-Regular.ttf", "OpenSans-Variable": "fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf", "OpenSans-Italic-Variable": "fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf", "Roboto-Variable": "fonts/Roboto/Roboto-VariableFont_wdth,wght.ttf", "Roboto-Italic-Variable": "fonts/Roboto/Roboto-Italic-VariableFont_wdth,wght.ttf", "SourceCodePro-Variable": "fonts/Source_Code_Pro/SourceCodePro-VariableFont_wght.ttf", "SourceCodePro-Italic-Variable": "fonts/Source_Code_Pro/SourceCodePro-Italic-VariableFont_wght.ttf", "Tektur-Variable": "fonts/Tektur/Tektur-VariableFont_wdth,wght.ttf", "Ponomar-Regular": "fonts/Ponomar/Ponomar-Regular.ttf", } # ---------------------------- # Колер -> RGB # ---------------------------- def hex_to_rgb(color_str: str): try: return ImageColor.getrgb(color_str) except Exception: pass lower = (color_str or "").lower() if lower.startswith("rgba") or lower.startswith("rgb"): inside = color_str[color_str.find("(") + 1 : color_str.rfind(")")] parts = [p.strip() for p in inside.split(",")] if len(parts) >= 3: try: r, g, b = [int(float(parts[i])) for i in range(3)] return (r, g, b) except Exception: pass c = (color_str or "#FFFFFF").lstrip("#") if len(c) == 3: c = "".join([ch * 2 for ch in c]) return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # ---------------------------- # SRT-парсінг # ---------------------------- def srt_time_to_sec(time_str): t = time_str.strip().replace(".", ",") if "," not in t: t += ",000" h, m, s_ms = t.split(":") s, ms = s_ms.split(",") return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000 def parse_srt_from_text(srt_text): subs = [] normalized = (srt_text or "").replace("\r\n", "\n").replace("\r", "\n") for block in normalized.strip().split("\n\n"): lines = block.splitlines() if len(lines) < 3: continue times = lines[1] try: start_str, end_str = times.split("-->") start = srt_time_to_sec(start_str) end = srt_time_to_sec(end_str) text = "\n".join(lines[2:]).strip() # захоўваем пераносы subs.append({"start": start, "end": end, "text": text}) except Exception: continue return subs # ---------------------------- # Wrap: аўтаматычны перанос радкоў па шырыні кадра # ---------------------------- def _line_width_px(draw: ImageDraw.ImageDraw, line: str, font: ImageFont.ImageFont, stroke_width: int = 0) -> int: try: l, t, r, b = draw.textbbox((0, 0), line, font=font, stroke_width=stroke_width) return int(r - l) except Exception: try: w = draw.textlength(line, font=font) return int(w + 2 * stroke_width) except Exception: w, _ = draw.textsize(line, font=font) return int(w + 2 * stroke_width) def _break_long_word(draw, word: str, font, max_width: int, stroke_width: int) -> list: chunks = [] cur = "" for ch in word: test = cur + ch if cur and _line_width_px(draw, test, font, stroke_width) > max_width: chunks.append(cur) cur = ch else: cur = test if cur: chunks.append(cur) return chunks def wrap_text_to_width(text: str, draw: ImageDraw.ImageDraw, font: ImageFont.ImageFont, max_width: int, stroke_width: int = 0) -> str: if not text: return text paragraphs = (text or "").split("\n") out_lines = [] for para in paragraphs: p = para.strip() if not p: out_lines.append("") continue words = p.split() line = "" for w in words: if not line: if _line_width_px(draw, w, font, stroke_width) <= max_width: line = w else: chunks = _break_long_word(draw, w, font, max_width, stroke_width) out_lines.extend(chunks[:-1]) line = chunks[-1] if chunks else "" continue test = f"{line} {w}" if _line_width_px(draw, test, font, stroke_width) <= max_width: line = test else: out_lines.append(line) if _line_width_px(draw, w, font, stroke_width) <= max_width: line = w else: chunks = _break_long_word(draw, w, font, max_width, stroke_width) out_lines.extend(chunks[:-1]) line = chunks[-1] if chunks else "" if line: out_lines.append(line) return "\n".join(out_lines) # ---------------------------- # Тэкставы кліп (PIL -> moviepy), без абразання # ---------------------------- def _measure_multiline_bbox(draw, text, font, stroke_width=0, spacing=4): try: return draw.multiline_textbbox( (0, 0), text, font=font, stroke_width=stroke_width, spacing=spacing, align="center", ) except Exception: pass try: return draw.textbbox((0, 0), text, font=font, stroke_width=stroke_width) except Exception: pass w, h = draw.textsize(text, font=font) h = h + max(2, stroke_width + 2) return (0, 0, w, h) def _clamp(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) def create_animated_text_clip( text, duration, font, fontsize, color, stroke_color, stroke_width, position_type, custom_x_shift, # ЗРУХ X (адносна цэнтра) custom_y, animation, video_width, video_height, bg_color=None, bg_opacity=1.0, wrap_ratio: float = 0.90, ): try: txt_rgb = hex_to_rgb(color) except Exception: txt_rgb = (255, 255, 255) try: stroke_rgb = hex_to_rgb(stroke_color) except Exception: stroke_rgb = (0, 0, 0) if bg_color: try: bg_rgb = hex_to_rgb(bg_color) except Exception: bg_rgb = (0, 0, 0) bg_a = int(max(0.0, min(1.0, bg_opacity)) * 255) else: bg_rgb = (0, 0, 0) bg_a = 0 try: pil_font = ImageFont.truetype(font, fontsize) except Exception: pil_font = ImageFont.load_default() dummy_img = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) dummy_draw = ImageDraw.Draw(dummy_img) spacing = max(0, int(fontsize * 0.15)) # --- AUTO WRAP --- max_text_width = max(200, int(video_width * float(wrap_ratio))) wrapped_text = wrap_text_to_width(text, dummy_draw, pil_font, max_text_width, stroke_width=stroke_width) # --- BBOX --- l, t, r, b = _measure_multiline_bbox(dummy_draw, wrapped_text, pil_font, stroke_width=stroke_width, spacing=spacing) text_w = r - l text_h = b - t pad_x = max(10, int(fontsize * 0.25)) pad_y = max(6, int(fontsize * 0.20)) safe = max(2, int(stroke_width) + 3) img_w = int(math.ceil(text_w + 2 * (pad_x + safe))) img_h = int(math.ceil(text_h + 2 * (pad_y + safe))) img = Image.new("RGBA", (img_w, img_h), bg_rgb + (bg_a,)) draw = ImageDraw.Draw(img) # галоўнае: улічваем bbox (можа быць адмоўны) text_x = (pad_x + safe) - l text_y = (pad_y + safe) - t draw.multiline_text( (text_x, text_y), wrapped_text, font=pil_font, fill=txt_rgb + (255,), stroke_width=stroke_width, stroke_fill=stroke_rgb + (255,), spacing=spacing, align="center", ) arr = np.array(img) rgb = arr[..., :3] alpha = arr[..., 3].astype(np.float32) / 255.0 clip = mpe.ImageClip(rgb, ismask=False).set_duration(duration) mask = mpe.ImageClip(alpha, ismask=True).set_duration(duration) clip = clip.set_mask(mask) # --- Пазіцыя (аптымізавана) --- # верх/ніз ссоўваем да цэнтра на 15% вышыні кадра center_pull = 0.15 * float(video_height) base_x_center = (video_width - clip.w) / 2 if position_type == "bottom": x = base_x_center y = (video_height - clip.h - 20) - center_pull elif position_type == "top": x = base_x_center y = 20 + center_pull elif position_type == "center": x = base_x_center y = (video_height - clip.h) / 2 else: # custom: Х заўсёды ад цэнтра (як ва ўсіх), custom_x_shift — дадатковы зрух x = base_x_center + float(custom_x_shift) y = float(custom_y) # clamp, каб не вылятала за кадр x = _clamp(x, 0, max(0, video_width - clip.w)) y = _clamp(y, 0, max(0, video_height - clip.h)) anim = (animation or "").lower() if anim == "fade": fd = min(0.5, duration / 2) clip = clip.fadein(fd).fadeout(fd) return clip.set_position((x, y)) elif anim == "slide": fd = min(0.5, duration / 2) def slide_pos(t_): progress = min(max(t_ / fd, 0), 1) return -clip.w + (x + clip.w) * progress, y return clip.set_position(slide_pos) elif anim == "zoom": clip = clip.resize(lambda t_: 0.5 + 0.5 * min(max(t_ / duration, 0), 1)) return clip.set_position((x, y)) else: return clip.set_position((x, y)) # ---------------------------- # Накладанне субтытраў # ---------------------------- def apply_subtitles( video_path, subtitles_text, font, fontsize, color, stroke_color, stroke_width, position_type, custom_x_shift, custom_y, animation, export_quality, bg_color=None, bg_opacity=1.0, wrap_ratio: float = 0.90, ): subs = parse_srt_from_text(subtitles_text) video = mpe.VideoFileClip(video_path) w, h = video.w, video.h clips = [video] for s in subs: dur = s["end"] - s["start"] if dur <= 0: continue txt_clip = create_animated_text_clip( s["text"], dur, font, fontsize, color, stroke_color, stroke_width, position_type, custom_x_shift, custom_y, animation, w, h, bg_color=bg_color, bg_opacity=bg_opacity, wrap_ratio=wrap_ratio, ).set_start(s["start"]) clips.append(txt_clip) final = mpe.CompositeVideoClip(clips) if export_quality == "мінімальнае": final = final.resize(height=480) elif export_quality == "сярэдняе": final = final.resize(height=720) elif export_quality == "максімальнае" and h < 1080: final = final.resize(height=1080) out = f"output_video_{uuid.uuid4().hex}.mp4" try: final.write_videofile(out, fps=video.fps, codec="libx264", audio_codec="aac") finally: video.close() final.close() return out # ---------------------------- # Перадпрагляд (1 секунда) # ---------------------------- def extract_first_frame(video_path): try: clip = mpe.VideoFileClip(video_path) frame = clip.get_frame(0) clip.close() return frame except Exception: return None def create_single_frame_video( frame, text, font, fontsize, color, stroke_color, stroke_width, position_type, custom_x_shift, custom_y, animation, bg_color=None, bg_opacity=1.0, wrap_ratio: float = 0.90, ): if frame is None: return None h, w, _ = frame.shape base = mpe.ImageClip(frame).set_duration(1.0) txt_clip = create_animated_text_clip( text, 1.0, font, fontsize, color, stroke_color, stroke_width, position_type, custom_x_shift, custom_y, animation, w, h, bg_color=bg_color, bg_opacity=bg_opacity, wrap_ratio=wrap_ratio, ) final_clip = mpe.CompositeVideoClip([base, txt_clip]) path = f"preview_video_{uuid.uuid4().hex}.mp4" try: final_clip.write_videofile( path, fps=24, codec="libx264", audio=False, verbose=False, logger=None, ) finally: final_clip.close() return path