import gradio as gr import subprocess import os import shutil import zipfile import math import glob import tempfile import torch # ========================================================= # ЕРӨНХИЙ ТОХИРГОО # ========================================================= WORK_DIR = "output_chunks" MUSIC_DIR = "music" FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # ========================================================= # TAB 1: ВИДЕО ТАСТАГЧ # ========================================================= def get_duration(path: str) -> float: cmd = [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path, ] result = subprocess.run(cmd, capture_output=True, text=True) return float(result.stdout.strip()) def time_to_seconds(t: str): t = (t or "").strip() if not t: return None parts = [int(p) for p in t.split(":")] while len(parts) < 3: parts.insert(0, 0) h, m, s = parts return h * 3600 + m * 60 + s def list_music_genres(): if not os.path.isdir(MUSIC_DIR): return ["(байхгүй)"] genres = sorted(d for d in os.listdir(MUSIC_DIR) if os.path.isdir(os.path.join(MUSIC_DIR, d))) return genres if genres else ["(байхгүй)"] def pick_music_track(genre: str): if not genre or genre == "(байхгүй)": return None folder = os.path.join(MUSIC_DIR, genre) tracks = glob.glob(os.path.join(folder, "*.mp3")) + glob.glob(os.path.join(folder, "*.wav")) return tracks[0] if tracks else None def run_ffmpeg(cmd, error_prefix="FFmpeg алдаа"): process = subprocess.run(cmd, capture_output=True, text=True) if process.returncode != 0: raise gr.Error(f"{error_prefix}:\n{process.stderr[-800:]}") def split_video( video_path, start_time, end_time, split_mode, minutes, seconds, num_parts, watermark_text, music_genre, music_volume, filename_prefix, make_thumbnails, progress=gr.Progress(), ): if video_path is None: raise gr.Error("Эхлээд видео файл оруулна уу.") if os.path.exists(WORK_DIR): shutil.rmtree(WORK_DIR) os.makedirs(WORK_DIR) current = video_path base_name = filename_prefix.strip() if filename_prefix and filename_prefix.strip() else \ os.path.splitext(os.path.basename(video_path))[0] start_s = time_to_seconds(start_time) end_s = time_to_seconds(end_time) if start_s is not None or end_s is not None: progress(0.05, desc="Сонгосон хэсгийг тайрч байна...") trimmed = os.path.join(WORK_DIR, "trimmed.mp4") cmd = ["ffmpeg", "-y"] if start_s is not None: cmd += ["-ss", str(start_s)] cmd += ["-i", current] if end_s is not None: duration = end_s - (start_s or 0) if duration <= 0: raise gr.Error("Төгсгөл цаг нь эхлэл цагаас хойш байх ёстой.") cmd += ["-t", str(duration)] cmd += ["-c", "copy", "-map", "0", trimmed] run_ffmpeg(cmd, "Тайрахад алдаа гарлаа") current = trimmed music_track = pick_music_track(music_genre) needs_filters = bool((watermark_text or "").strip()) or music_track is not None if needs_filters: progress(0.2, desc="Watermark/ая нэмж байна...") processed = os.path.join(WORK_DIR, "processed.mp4") cmd = ["ffmpeg", "-y", "-i", current] filter_complex_parts = [] map_video = "0:v" map_audio = "0:a" if (watermark_text or "").strip(): text = watermark_text.strip().replace("'", "\\'").replace(":", "\\:") filter_complex_parts.append( f"[0:v]drawtext=fontfile={FONT_PATH}:text='{text}':" f"fontcolor=white@0.8:fontsize=28:x=w-tw-20:y=h-th-20:" f"box=1:boxcolor=black@0.4:boxborderw=8[vout]" ) map_video = "[vout]" if music_track is not None: cmd += ["-i", music_track] vol = max(0.0, min(1.0, float(music_volume) / 100.0)) filter_complex_parts.append(f"[1:a]volume={vol},aloop=loop=-1:size=2000000000[music]") filter_complex_parts.append(f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[aout]") map_audio = "[aout]" cmd += ["-filter_complex", ";".join(filter_complex_parts)] cmd += ["-map", map_video, "-map", map_audio] cmd += ["-c:v", "libx264", "-preset", "veryfast", "-c:a", "aac", "-shortest", processed] run_ffmpeg(cmd, "Watermark/ая нэмэхэд алдаа гарлаа") current = processed progress(0.5, desc="Видеог хуваалж байна...") total_duration = get_duration(current) if split_mode == "Тэнцүү N хэсэгт": n = max(1, int(num_parts)) chunk_seconds = math.ceil(total_duration / n) else: chunk_seconds = int(minutes) * 60 + int(seconds) if chunk_seconds <= 0: raise gr.Error("Хуваах хугацаа 0-ээс их байх ёстой.") output_pattern = os.path.join(WORK_DIR, f"{base_name}_%03d.mp4") cmd = [ "ffmpeg", "-y", "-i", current, "-c", "copy", "-map", "0", "-segment_time", str(chunk_seconds), "-f", "segment", "-reset_timestamps", "1", output_pattern, ] process = subprocess.run(cmd, capture_output=True, text=True) if process.returncode != 0: cmd_reencode = [ "ffmpeg", "-y", "-i", current, "-map", "0", "-segment_time", str(chunk_seconds), "-f", "segment", "-reset_timestamps", "1", output_pattern, ] run_ffmpeg(cmd_reencode, "Хуваахад алдаа гарлаа") progress(0.85, desc="Файлуудыг цуглуулж байна...") files = sorted(glob.glob(os.path.join(WORK_DIR, f"{base_name}_*.mp4"))) if not files: raise gr.Error("Хуваалт амжилтгүй боллоо - гаралтын файл олдсонгүй.") thumb_files = [] if make_thumbnails: progress(0.92, desc="Thumbnail үүсгэж байна...") for f in files: thumb_path = os.path.splitext(f)[0] + "_thumb.jpg" cmd = ["ffmpeg", "-y", "-ss", "1", "-i", f, "-vframes", "1", thumb_path] subprocess.run(cmd, capture_output=True, text=True) if os.path.exists(thumb_path): thumb_files.append(thumb_path) zip_path = os.path.join(WORK_DIR, f"{base_name}_parts.zip") with zipfile.ZipFile(zip_path, "w") as zf: for f in files + thumb_files: zf.write(f, os.path.basename(f)) progress(1.0, desc="Дууслаа!") status = f"✅ {len(files)} хэсэгт хуваалаа (тус бүр ~{chunk_seconds // 60} мин {chunk_seconds % 60} сек)" if music_track: status += f" | Ая: {os.path.basename(music_track)}" return status, files + thumb_files, zip_path # ========================================================= # TAB 2: ДУУ ХООЛОЙ КЛОН (OmniVoice, CPU, lazy-load) # ========================================================= _voice_model = None def get_voice_model(): """Загварыг зөвхөн энэ tab-ыг эхлээд ашиглах үед л ачаална - splitter tab-ын хурдад нөлөөлөхгүй.""" global _voice_model if _voice_model is None: from omnivoice import OmniVoice _voice_model = OmniVoice.from_pretrained( "k2-fsa/OmniVoice", device_map="cpu", dtype=torch.float32, ) _voice_model.eval() return _voice_model def clone_voice(text, ref_audio, ref_text, language, progress=gr.Progress()): if not text or not text.strip(): raise gr.Error("Хэлэх текстээ оруулна уу.") if ref_audio is None: raise gr.Error("Дуу хоолойн жишээ (3-30 сек, ӨӨРИЙН эсвэл ашиглах эрхтэй хоолой) оруулна уу.") progress(0.05, desc="Загвар ачааллаж байна (эхний удаад л удаан)...") model = get_voice_model() progress(0.3, desc="Дуу хоолой үүсгэж байна (CPU тул хүлээнэ үү)...") kwargs = {"text": text.strip(), "ref_audio": ref_audio} if ref_text and ref_text.strip(): kwargs["ref_text"] = ref_text.strip() if language and language.strip(): kwargs["language"] = language.strip() with torch.no_grad(): audio = model.generate(**kwargs) progress(0.9, desc="Файл бэлдэж байна...") out_path = tempfile.mktemp(suffix=".wav") result = audio[0] if isinstance(result, torch.Tensor): import torchaudio torchaudio.save(out_path, result if result.dim() == 2 else result.unsqueeze(0), 24000) else: import soundfile as sf sf.write(out_path, result, 24000) progress(1.0, desc="Дууслаа!") return out_path # ========================================================= # TAB 3: БАГЦ ДУБЛАЖ (Segment Dubbing) - тогтвортой НЭГ хоолойтой, # timestamp-аар синк хийж, өнгө/хурд/дуу чанга 3 горимтой # ========================================================= import re import numpy as np SEGMENT_LINE_RE = re.compile( r"^\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*-\s*(\d{1,2}:\d{2}(?::\d{2})?)\s*:\s*(.+?)\s*$" ) def _parse_ts(ts: str) -> float: parts = [int(p) for p in ts.split(":")] while len(parts) < 3: parts.insert(0, 0) h, m, s = parts return h * 3600 + m * 60 + s def parse_segments(raw_text: str): """ Мөр бүрийг задална. Дэмждэг формат: 00:00-00:03: Сайн байна уу 00:03-00:07: Энэ бол жишээ өгүүлбэр Timestamp байхгүй бол зүгээр текст мөр гэж үзнэ (duration sync хийхгүй). Буцаах: list[dict(start, end, duration, text)] """ segments = [] for raw_line in raw_text.splitlines(): line = raw_line.strip() if not line: continue m = SEGMENT_LINE_RE.match(line) if m: start_s = _parse_ts(m.group(1)) end_s = _parse_ts(m.group(2)) text = m.group(3).strip() if end_s <= start_s: raise gr.Error(f"Буруу мөр (эцэс нь эхлэлээс өмнө байна): {line}") segments.append({ "start": start_s, "end": end_s, "duration": end_s - start_s, "text": text, }) else: segments.append({"start": None, "end": None, "duration": None, "text": line}) if not segments: raise gr.Error("Дор хаяж нэг мөр текст оруулна уу.") return segments def postprocess_audio(y: np.ndarray, sr: int, speed: float, pitch_semitones: float, gain_db: float) -> np.ndarray: """3 горим: хурд (speed), өнгө (pitch), дуу чанга (gain) - үүсгэсэн ЯГ ТЭР дуу хоолойг өөрчлөхгүйгээр тохируулна.""" import librosa y = np.asarray(y, dtype=np.float32) if y.ndim > 1: y = y.mean(axis=0) if speed and abs(speed - 1.0) > 1e-3: y = librosa.effects.time_stretch(y, rate=float(speed)) if pitch_semitones and abs(pitch_semitones) > 1e-3: y = librosa.effects.pitch_shift(y, sr=sr, n_steps=float(pitch_semitones)) if gain_db and abs(gain_db) > 1e-3: y = y * (10.0 ** (gain_db / 20.0)) peak = np.max(np.abs(y)) if y.size else 0.0 if peak > 0.99: y = y / peak * 0.99 return y.astype(np.float32) def _generate_one(model, text, ref_audio, ref_text, language, duration): kwargs = {"text": text.strip(), "ref_audio": ref_audio} # ref_audio/ref_text-г БҮХ сегментэд яг ижилхэн дамжуулж байгаа тул # хоолой хооронд солигдохгүй, тогтвортой байна. if ref_text and ref_text.strip(): kwargs["ref_text"] = ref_text.strip() if language and language.strip(): kwargs["language"] = language.strip() if duration: kwargs["duration"] = float(duration) # timestamp-тай тааруулж синк хийнэ with torch.no_grad(): audio = model.generate(**kwargs) result = audio[0] if isinstance(result, torch.Tensor): arr = result.squeeze().cpu().numpy() else: arr = np.asarray(result) return arr def batch_dub( segments_text, ref_audio, ref_text, language, speed, pitch_semitones, gain_db, fill_gaps, progress=gr.Progress(), ): if not segments_text or not segments_text.strip(): raise gr.Error("Сегментүүдээ оруулна уу.") if ref_audio is None: raise gr.Error("Дуу хоолойн жишээ (нэг л удаа) оруулна уу - энэ бүх сегментэд адилхан ашиглагдана.") segments = parse_segments(segments_text) progress(0.02, desc="Загвар ачааллаж байна...") model = get_voice_model() sr = 24000 piece_dir = tempfile.mkdtemp(prefix="dub_segments_") timeline_pieces = [] per_segment_files = [] prev_end = None total = len(segments) for i, seg in enumerate(segments): progress(0.05 + 0.85 * (i / total), desc=f"Сегмент {i + 1}/{total} үүсгэж байна...") # timestamp-той бол сегментүүдийн хоорондох завсрыг чимээгүй хэсгээр дүүргэнэ if fill_gaps and prev_end is not None and seg["start"] is not None: gap = seg["start"] - prev_end if gap > 0.02: timeline_pieces.append(np.zeros(int(gap * sr), dtype=np.float32)) raw = _generate_one(model, seg["text"], ref_audio, ref_text, language, seg["duration"]) processed = postprocess_audio(raw, sr, speed, pitch_semitones, gain_db) timeline_pieces.append(processed) seg_path = os.path.join(piece_dir, f"segment_{i + 1:03d}.wav") import soundfile as sf sf.write(seg_path, processed, sr) per_segment_files.append(seg_path) if seg["end"] is not None: prev_end = seg["end"] progress(0.92, desc="Бүх сегментийг нэгтгэж байна...") final_audio = np.concatenate(timeline_pieces) if timeline_pieces else np.zeros(0, dtype=np.float32) final_path = os.path.join(piece_dir, "dubbed_full.wav") import soundfile as sf sf.write(final_path, final_audio, sr) zip_path = os.path.join(piece_dir, "segments.zip") with zipfile.ZipFile(zip_path, "w") as zf: for f in per_segment_files: zf.write(f, os.path.basename(f)) progress(1.0, desc="Дууслаа!") status = f"✅ {total} сегмент нэг тогтвортой хоолойгоор үүслээ (нийт {len(final_audio) / sr:.1f} сек)." return status, final_path, zip_path # ========================================================= # НЭГТГЭСЭН ИНТЕРФЭЙС # ========================================================= with gr.Blocks(title="КиноЭзэн Студи") as demo: gr.Markdown("# 🎬 КиноЭзэн Студи") gr.Markdown("Нэг сайт дотор: видео тастагч + дуу хоолой клон (dubbing).") with gr.Tabs(): with gr.Tab("✂️ Видео Тастагч"): video_input = gr.Video(label="Видео оруулах") with gr.Accordion("Хугацааны диапазон (заавал биш)", open=False): gr.Markdown("Формат: MM:SS эсвэл HH:MM:SS") with gr.Row(): start_input = gr.Textbox(label="Эхлэх цаг", placeholder="05:00") end_input = gr.Textbox(label="Дуусах цаг", placeholder="25:00") split_mode = gr.Radio( ["Тогтмол хугацаагаар", "Тэнцүү N хэсэгт"], value="Тогтмол хугацаагаар", label="Хуваах горим", ) with gr.Row(): minutes_input = gr.Number(label="Минут (хэсэг тус бүр)", value=10, precision=0, minimum=0) seconds_input = gr.Number(label="Секунд", value=0, precision=0, minimum=0, maximum=59) num_parts_input = gr.Number(label="Хэдэн хэсэгт (тэнцүү горимд)", value=3, precision=0, minimum=1) with gr.Accordion("Watermark (заавал биш)", open=False): watermark_input = gr.Textbox(label="Watermark текст", placeholder="КиноЭзэн") with gr.Accordion("Дэвсгэр ая (заавал биш)", open=False): gr.Markdown("`music/<жанр>/` хавтаст royalty-free mp3/wav байршуулсан бол энд сонгоно.") music_genre_input = gr.Dropdown(choices=list_music_genres(), value="(байхгүй)", label="Жанр") refresh_btn = gr.Button("Жанр шинэчлэх", size="sm") music_volume_input = gr.Slider(0, 100, value=15, label="Аяны түвшин (%)") with gr.Row(): filename_prefix_input = gr.Textbox(label="Файлын нэрийн эхлэл (заавал биш)") thumbnail_checkbox = gr.Checkbox(label="Thumbnail үүсгэх", value=False) split_btn = gr.Button("Хуваах", variant="primary") status_output = gr.Textbox(label="Төлөв", interactive=False) files_output = gr.File(label="Тусдаа файлууд", file_count="multiple") zip_output = gr.File(label="ZIP татаж авах (бүгд)") refresh_btn.click(fn=lambda: gr.update(choices=list_music_genres()), outputs=music_genre_input) split_btn.click( fn=split_video, inputs=[ video_input, start_input, end_input, split_mode, minutes_input, seconds_input, num_parts_input, watermark_input, music_genre_input, music_volume_input, filename_prefix_input, thumbnail_checkbox, ], outputs=[status_output, files_output, zip_output], ) with gr.Tab("🎙️ Дуу Хоолой Клон"): gr.Markdown( "⚠️ **Зөвхөн өөрийн эсвэл ашиглах эрхтэй дуу хоолойгоо клонд ашиглана уу.** " "CPU дээр ажилладаг тул quota-гүй, гэхч GPU-той харьцуулахад удаан (~30-90 сек/өгүүлбэр)." ) with gr.Row(): with gr.Column(): text_input = gr.Textbox(label="Хэлэх текст (Монгол)", lines=4) ref_audio_input = gr.Audio(label="Дуу хоолойн жишээ (3-30 сек)", type="filepath") ref_text_input = gr.Textbox(label="Жишээ аудионы бичвэр (заавал биш)", lines=2) language_input = gr.Textbox(label="Хэл", value="Mongolian") generate_btn = gr.Button("Дуу хоолой үүсгэх", variant="primary") with gr.Column(): audio_output = gr.Audio(label="Гарсан дуу хоолой") generate_btn.click( fn=clone_voice, inputs=[text_input, ref_audio_input, ref_text_input, language_input], outputs=audio_output, ) with gr.Tab("🎬 Багц Дублаж"): gr.Markdown( "**Видео бүхэлд нь НЭГ л тогтвортой хоолойгоор дублаж хийнэ.** " "Reference audio-г зөвхөн нэг удаа сонгоно - бүх сегментэд яг адилхан ашиглагдана, " "тиймээс хоолой сегмент хооронд солигдохгүй.\n\n" "**Сегментийн формат** (мөр тус бүр нэг репликтой):\n" "```\n00:00-00:03: Сайн байна уу\n00:03-00:07: Энэ бол жишээ өгүүлбэр\n```\n" "Timestamp өгвөл (Claude API-аас гарсан орчуулгын хугацаатай тааруулж) загвар " "яг тэр л урттай хоолой гаргаж, синк алдагдахгүй. Timestamp-гүй бол зүгээр мөр мөрөөр " "текст оруулж болно (тухайн үед speed slider-ээр хурдыг тохируулна)." ) with gr.Row(): with gr.Column(): segments_input = gr.Textbox( label="Сегментүүд", lines=10, placeholder="00:00-00:03: Сайн байна уу\n00:03-00:07: Энэ бол жишээ өгүүлбэр", ) batch_ref_audio_input = gr.Audio( label="Дуу хоолойн жишээ (НЭГ Л УДАА сонгоно - бүх сегментэд адилхан хэрэглэгдэнэ)", type="filepath", ) batch_ref_text_input = gr.Textbox(label="Жишээ аудионы бичвэр (заавал биш)", lines=2) batch_language_input = gr.Textbox(label="Хэл", value="Mongolian") gr.Markdown("**3 горим (post-processing):**") speed_slider = gr.Slider(0.7, 1.4, value=1.0, step=0.01, label="⏩ Хурд (1.0 = хэвийн)") pitch_slider = gr.Slider(-6, 6, value=0.0, step=0.5, label="🎚️ Өнгө: нарийн ⟷ бүдүүн (semitone)") gain_slider = gr.Slider(-10, 10, value=0.0, step=0.5, label="🔊 Дуу чанга/султгах (dB)") fill_gaps_checkbox = gr.Checkbox( label="Сегментүүдийн хоорондох завсрыг чимээгүйгээр дүүргэх (timestamp-той үед)", value=True, ) batch_generate_btn = gr.Button("Багц дублаж эхлүүлэх", variant="primary") with gr.Column(): batch_status_output = gr.Textbox(label="Төлөв", interactive=False) batch_audio_output = gr.Audio(label="Бүтэн дубласан аудио") batch_zip_output = gr.File(label="Сегмент бүрийн ZIP (тус тусад нь)") batch_generate_btn.click( fn=batch_dub, inputs=[ segments_input, batch_ref_audio_input, batch_ref_text_input, batch_language_input, speed_slider, pitch_slider, gain_slider, fill_gaps_checkbox, ], outputs=[batch_status_output, batch_audio_output, batch_zip_output], ) if __name__ == "__main__": demo.launch()