#!/usr/bin/env python3 """ app.py — HebSub על HuggingFace Spaces שני שלבים: תמלול → עריכה בטבלה → צריבה תומך במצב קריוקי — הדגשת מילה נוכחית גרסה 5.1 — faster-whisper + FFmpeg מואץ + הגנת timeout + הצגת זמן משוער """ import shutil import tempfile import subprocess from pathlib import Path from datetime import timedelta import pandas as pd import gradio as gr # ─── faster-whisper במקום openai-whisper ─────────────────────────────────── from faster_whisper import WhisperModel import time as _time OUTPUT_DIR = Path("/tmp/hebsub_output") OUTPUT_DIR.mkdir(exist_ok=True) # ─── הגנת timeout: מקסימום זמן תמלול (שניות) ─────────────────────────── MAX_TRANSCRIBE_SECONDS = 300 # 5 דקות — אם לקח יותר, עוצרים בנחת def cleanup_old_outputs(max_age_seconds: int = 3600): """ מחק קבצים ותיקיות ישנים — מונע מילוי דיסק ב-HuggingFace Spaces. רץ בכל צריבה. מנקה: 1. קבצי MP4 בתיקיית הפלט (סרטונים ישנים) 2. תיקיות עבודה זמניות שנשארו (אחרי תמלולים כושלים) """ now = _time.time() # נקה MP4 ישנים for f in OUTPUT_DIR.glob("*.mp4"): try: if now - f.stat().st_mtime > max_age_seconds: f.unlink() except Exception: pass # נקה תיקיות עבודה זמניות ישנות (WAV, ASS וכו') # כולל תיקיות של /tmp/tmp* שנוצרו על ידי tempfile.mkdtemp for tmp_dir in Path("/tmp").glob("tmp*"): try: if tmp_dir.is_dir() and now - tmp_dir.stat().st_mtime > max_age_seconds: shutil.rmtree(tmp_dir, ignore_errors=True) except Exception: pass def ts_to_str(seconds: float, ass: bool = False) -> str: td = timedelta(seconds=max(0, seconds)) h = int(td.total_seconds() // 3600) m = int((td.total_seconds() % 3600) // 60) s = int(td.total_seconds() % 60) ms = int((td.total_seconds() % 1) * 1000) if ass: return f"{h}:{m:02d}:{s:02d}.{ms//10:02d}" return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" def parse_ts(s: str) -> float: try: s = s.strip().replace(",", ".") parts = s.split(":") if len(parts) != 3: return 0.0 h, m, sec = float(parts[0]), float(parts[1]), float(parts[2]) return h * 3600 + m * 60 + sec except Exception: return 0.0 HEBREW_FIXES = { "בסדר": ["בסד", "בסד'"], "אוקיי": ["אוקי", "וקיי"], "בערך": ["בעירך"], "הרבה": ["הרב"], "יכול": ["יוכל", "יכל"], "צריך": ["צרי"], } def fix_hebrew(text: str) -> str: for correct, variants in HEBREW_FIXES.items(): for v in variants: text = text.replace(v, correct) while " " in text: text = text.replace(" ", " ") return text.strip() def color_to_rgb(color_str): import re, colorsys s = str(color_str).strip() if color_str else "" if not s: return (255, 255, 255) m = re.search(r"hsla?\s*\(\s*([\d.]+)\s*,\s*([\d.]+)%?\s*,\s*([\d.]+)%?", s) if m: h = float(m.group(1)) / 360.0 sv = float(m.group(2)) / 100.0 lv = float(m.group(3)) / 100.0 r, g, b = colorsys.hls_to_rgb(h, lv, sv) return (round(r * 255), round(g * 255), round(b * 255)) m = re.search(r"rgba?\s*\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)", s) if m: return ( max(0, min(255, round(float(m.group(1))))), max(0, min(255, round(float(m.group(2))))), max(0, min(255, round(float(m.group(3))))), ) h = s.lstrip("#").strip() if len(h) == 3: h = h[0] * 2 + h[1] * 2 + h[2] * 2 if len(h) == 6: try: return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)) except ValueError: pass return (255, 255, 255) def hex_to_ass(hex_color: str) -> str: rr, gg, bb = color_to_rgb(hex_color) return f"&H00{bb:02X}{gg:02X}{rr:02X}" def ass_escape(text: str) -> str: if text is None: return "" text = str(text) text = text.replace("\\", r"\\") text = text.replace("{", r"\{") text = text.replace("}", r"\}") return text def split_chunks(segments, max_chars=22, max_dur=4.5, min_dur=1.2): chunks = [] for seg in segments: text = fix_hebrew(seg.get("text", "").strip()) start = seg["start"] end = seg["end"] dur = end - start if dur <= max_dur and len(text) <= max_chars * 2: chunks.append({"start": start, "end": end, "text": text}) continue words = text.split() if not words: continue word_dur = dur / max(len(words), 1) cur_words, cur_start = [], start for i, word in enumerate(words): cur_words.append(word) cur_text = " ".join(cur_words) cur_end = start + (i + 1) * word_dur cur_dur = cur_end - cur_start if len(cur_text) >= max_chars or cur_dur >= max_dur or i == len(words) - 1: if cur_dur < min_dur and i < len(words) - 1: continue chunks.append({"start": cur_start, "end": min(cur_end, end), "text": fix_hebrew(cur_text)}) cur_words, cur_start = [], cur_end return chunks def build_karaoke_chunks(segments, max_chars=22, max_dur=4.5): karaoke_chunks = [] for seg in segments: words = seg.get("words", []) if not words: # אין word timestamps — מפצלים לפי מילים עם זמנים מוערכים text = fix_hebrew(seg.get("text", "").strip()) word_list = text.split() if not word_list: continue seg_dur = seg["end"] - seg["start"] word_dur = seg_dur / len(word_list) estimated_words = [] for wi, w in enumerate(word_list): w_start = seg["start"] + wi * word_dur w_end = seg["start"] + (wi + 1) * word_dur estimated_words.append({"word": w, "start": w_start, "end": w_end}) # מפצלים ל-chunks לפי max_chars / max_dur cur_words = [] cur_start = estimated_words[0]["start"] for i, w in enumerate(estimated_words): cur_words.append(w) cur_text = " ".join(cw["word"] for cw in cur_words) cur_dur = w["end"] - cur_start if len(cur_text) >= max_chars or cur_dur >= max_dur or i == len(estimated_words) - 1: karaoke_chunks.append({"start": cur_start, "end": w["end"], "words": cur_words[:]}) cur_words = [] if i + 1 < len(estimated_words): cur_start = estimated_words[i + 1]["start"] continue cur_words = [] cur_start = words[0].get("start", seg["start"]) for i, w in enumerate(words): word_text = fix_hebrew(w.get("word", "").strip()) if not word_text: continue w_start = w.get("start", cur_start) w_end = w.get("end", w_start + 0.3) cur_words.append({"word": word_text, "start": w_start, "end": w_end}) cur_text = " ".join(cw["word"] for cw in cur_words) cur_dur = cur_words[-1]["end"] - cur_start if len(cur_text) >= max_chars or cur_dur >= max_dur or i == len(words) - 1: if cur_words: karaoke_chunks.append({"start": cur_start, "end": cur_words[-1]["end"], "words": cur_words[:]}) cur_words = [] if i + 1 < len(words): cur_start = words[i + 1].get("start", w_end) return karaoke_chunks def chunks_to_df(chunks: list[dict]) -> pd.DataFrame: rows = [] for c in chunks: rows.append({"התחלה": ts_to_str(c["start"])[:8], "סיום": ts_to_str(c["end"])[:8], "טקסט": c["text"]}) return pd.DataFrame(rows, columns=["התחלה", "סיום", "טקסט"]) def df_to_chunks(df) -> list[dict]: chunks = [] if isinstance(df, dict): rows = df.get("data", []) for row in rows: try: if len(row) < 3: continue start = parse_ts(str(row[0])) end = parse_ts(str(row[1])) text = str(row[2]).strip() if text and text not in ("nan", ""): chunks.append({"start": start, "end": end, "text": text}) except Exception: continue return chunks try: for _, row in df.iterrows(): try: if "התחלה" in df.columns: start = parse_ts(str(row["התחלה"])) end = parse_ts(str(row["סיום"])) text = str(row["טקסט"]).strip() else: start = parse_ts(str(row.iloc[0])) end = parse_ts(str(row.iloc[1])) text = str(row.iloc[2]).strip() if text and text not in ("nan", ""): chunks.append({"start": start, "end": end, "text": text}) except Exception: continue except Exception: pass return chunks def empty_df() -> pd.DataFrame: return pd.DataFrame(columns=["התחלה", "סיום", "טקסט"]) def build_ass_content(chunks: list[dict], style: dict) -> str: font = style.get("font", "Impact") hook_size = style.get("hook_size", 85) body_size = style.get("body_size", 62) hook_col = style.get("hook_color", "&H0000FFFF") body_col = style.get("body_color", "&H00FFFFFF") outline = style.get("outline", 3) shadow = style.get("shadow", 2) alignment = style.get("alignment", 2) margin_v = style.get("margin_v", 80) hook_lines = style.get("hook_lines", 2) header = f"""[Script Info] ScriptType: v4.00+ PlayResX: 1080 PlayResY: 1920 ScaledBorderAndShadow: yes WrapStyle: 0 Kerning: yes [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Hook,{font},{hook_size},{hook_col},&H00000000,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,{outline},{shadow},{alignment},40,40,{margin_v},0 Style: Body,{font},{body_size},{body_col},&H00000000,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,{outline},{shadow},{alignment},40,40,{margin_v},0 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ lines = [header] for i, chunk in enumerate(chunks): s = ts_to_str(chunk["start"], ass=True) e = ts_to_str(chunk["end"], ass=True) style_name = "Hook" if i < hook_lines else "Body" text = ass_escape(chunk["text"]) lines.append(f"Dialogue: 0,{s},{e},{style_name},,0,0,0,,{text}\n") return "".join(lines) def build_ass_popup(karaoke_chunks: list[dict], style: dict) -> str: """ Pop-up subtitles — TikTok style: כל מילה מופיעה גדולה ובולטת בזמן שנאמרת, ואז נעלמת. """ font = style.get("font", "Impact") body_size = style.get("body_size", 62) popup_size = int(style.get("hook_size", 85) * 1.2) highlight = style.get("karaoke_color", "&H0000FFFF") body_col = style.get("body_color", "&H00FFFFFF") outline = style.get("outline", 3) shadow = style.get("shadow", 2) alignment = style.get("alignment", 2) margin_v = style.get("margin_v", 80) hook_lines = style.get("hook_lines", 2) header = f"""[Script Info] ScriptType: v4.00+ PlayResX: 1080 PlayResY: 1920 ScaledBorderAndShadow: yes WrapStyle: 0 [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Popup,{font},{popup_size},{highlight},&H00000000,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,{outline},{shadow},{alignment},40,40,{margin_v},0 Style: Base,{font},{body_size},{body_col},&H00000000,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,{outline},{shadow},{alignment},40,40,{margin_v},0 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ result = [header] for i, chunk in enumerate(karaoke_chunks): style_base = "Popup" if i < hook_lines else "Base" words = chunk.get("words", []) if not words: s = ts_to_str(chunk["start"], ass=True) e = ts_to_str(chunk["end"], ass=True) text = ass_escape(chunk.get("text", "")) result.append(f"Dialogue: 0,{s},{e},{style_base},,0,0,0,,{text}\n") continue for wi, w in enumerate(words): word = ass_escape(w["word"].strip()) if not word: continue ws = ts_to_str(w.get("start", chunk["start"]), ass=True) if wi + 1 < len(words): we = ts_to_str(words[wi + 1].get("start", w.get("end", chunk["end"])), ass=True) else: we = ts_to_str(w.get("end", chunk["end"]), ass=True) result.append(f"Dialogue: 0,{ws},{we},Popup,,0,0,0,,{word}\n") return "".join(result) def build_ass_karaoke(karaoke_chunks, style): """alias — מפנה ל-popup.""" return build_ass_popup(karaoke_chunks, style) # ─── מנהל מודל faster-whisper (singleton עם cache) ─────────────────────── import threading as _threading _whisper_model = None _whisper_model_size = None _model_lock = _threading.Lock() # ─── נעילת תמלול: רק משתמש אחד יכול לתמלל בו-זמנית ───────────────────── # כמו תור בסופר — מי שהגיע ראשון מתמלל, השני מחכה בחוץ. _transcribe_lock = _threading.Lock() # ─── נעילת צריבה: רק משתמש אחד יכול לצרוב בו-זמנית ────────────────────── # צריבה במקביל = שני FFmpeg על אותו CPU = קבצים פגומים _burn_lock = _threading.Lock() def get_model(model_size: str): global _whisper_model, _whisper_model_size with _model_lock: if _whisper_model is None or _whisper_model_size != model_size: # int8 = מהיר יותר על CPU ב-~30%, עם אובדן איכות מינימלי _whisper_model = WhisperModel(model_size, device="cpu", compute_type="int8") _whisper_model_size = model_size return _whisper_model MAX_DURATION_SECS = 90 MAX_FILE_MB = 500 def estimate_transcribe_time(audio_dur: float, model_size: str) -> tuple[int, int, int]: """ מחשב זמן תמלול משוער בשניות. מחזיר: (מינימום, אופטימי, מקסימום) בשניות. """ # קצב עיבוד לפי מודל (שניות אודיו לשנייה אחת של עיבוד) # מהירויות מעודכנות לפי מדידות אמיתיות על HuggingFace CPU # (שניות אודיו לכל שנייה אחת של עיבוד) speed = {"base": 2.0, "small": 1.5, "medium": 0.7, "large-v3": 0.3}.get(model_size, 0.7) overhead = 6 # טעינה + VAD + חלוקה לכתוביות expected = overhead + max(1, int(audio_dur / speed)) return int(expected * 0.8), expected, int(expected * 1.4) def do_transcribe(video_path, model_size, trim_start, trim_end, karaoke_mode=False, progress=gr.Progress()): if video_path is None: return "❌ לא נבחר קובץ וידאו", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) file_mb = Path(video_path).stat().st_size / 1024 / 1024 if file_mb > MAX_FILE_MB: return ( f"❌ הקובץ גדול מדי ({file_mb:.0f}MB). מגבלה: {MAX_FILE_MB}MB.\n" f"💡 דחוס את הוידאו או חתוך קטע קצר יותר.", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) ) raw_dur = get_video_duration(video_path) if raw_dur > 0: effective_end = min(trim_end, raw_dur) if trim_end > 0 else raw_dur effective_start = max(0, trim_start) effective_dur = effective_end - effective_start if effective_dur > MAX_DURATION_SECS: est_min = int(effective_dur * 10 / 60) return ( f"❌ הוידאו ארוך מדי ({effective_dur:.0f} שניות). מגבלה: {MAX_DURATION_SECS} שניות.\n" f"⏱️ תמלול היה לוקח ~{est_min} דקות — נסה לחתוך קטע קצר יותר בהגדרות המתקדמות.", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) ) # ─── תור תמלול: רק משתמש אחד בו-זמנית — כמו תור בסופר ───────────── # אם מישהו אחר כבר מתמלל, השני מקבל הודעה נחמדה ולא קריסה if not _transcribe_lock.acquire(blocking=False): return ( "⏳ המערכת עסוקה כרגע בתמלול של משתמש אחר.\n" "אנא המתן 30–60 שניות ונסה שוב — התור קצר!", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) ) workdir = Path(tempfile.mkdtemp()) video = Path(video_path) try: _transcribe_start_time = _time.time() progress(0.05, desc="⚡ מכין קובץ אודיו...") audio_path = workdir / "audio.wav" dur = raw_dur # שימוש בערך שכבר חישבנו — חוסך קריאת ffprobe נוספת use_trim = dur > 0 and (trim_start > 0 or trim_end < dur - 1) ffmpeg_cmd = ["ffmpeg", "-y"] if use_trim: ffmpeg_cmd += ["-ss", str(trim_start), "-to", str(trim_end)] ffmpeg_cmd += ["-i", str(video), "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", str(audio_path)] r = subprocess.run(ffmpeg_cmd, capture_output=True) if r.returncode != 0: return "❌ FFmpeg שגיאה בחילוץ אודיו. ודא שהקובץ תקין.", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) progress(0.15, desc=f"🧠 טוען מודל Whisper ({model_size})...") model = get_model(model_size) try: # WAV PCM s16le mono 16000Hz — חישוב ישיר מגודל הקובץ, בלי ffprobe # bytes / (sample_rate * bytes_per_sample) = שניות wav_bytes = audio_path.stat().st_size - 44 # 44 = WAV header audio_dur = max(1.0, wav_bytes / (16000 * 2)) except Exception: audio_dur = dur if dur > 0 else 60.0 # ─── חישוב זמן משוער — UX: המשתמש יודע כמה לחכות ─────────────── t_min, t_mid, t_max = estimate_transcribe_time(audio_dur, model_size) if t_mid < 60: time_str = f"~{t_mid} שניות" range_str = f"{t_min}–{t_max} שניות" else: mid_m, mid_s = divmod(t_mid, 60) max_m, max_s = divmod(t_max, 60) time_str = f"~{mid_m}:{mid_s:02d} דקות" range_str = f"עד {max_m}:{max_s:02d} דקות" progress(0.20, desc=f"🎙️ מתמלל... ({time_str} — טווח: {range_str})") _t0 = _time.time() # ─── faster-whisper: streaming generator עם progress בזמן אמת ─── segments_gen, info = model.transcribe( str(audio_path), language="he", word_timestamps=bool(karaoke_mode), # רק במצב Popup — חוסך ~50% זמן על סרטונים קצרים task="transcribe", vad_filter=True, vad_parameters=dict(min_silence_duration_ms=300), initial_prompt="זהו תמלול בעברית מדוברת ישראלית. כולל מילים כמו: בסדר, אוקיי, כאילו, ממש, בעצם, יאללה, חבר, אחלה.", ) raw_segments = [] total_dur = info.duration or audio_dur for seg in segments_gen: # ─── בדיקת timeout בתוך לולאת התמלול — בלי signal ────────── if _time.time() - _transcribe_start_time > MAX_TRANSCRIBE_SECONDS: raise TimeoutError("transcribe_timeout") raw_segments.append(seg) pct = min(0.88, 0.20 + (seg.end / total_dur) * 0.68) elapsed = int(_time.time() - _t0) em, es = divmod(elapsed, 60) remaining = max(0, t_mid - elapsed) rm, rs = divmod(remaining, 60) if remaining > 5: remain_str = f" — נותרו ~{rm}:{rs:02d}" if rm > 0 else f" — נותרו ~{rs}ש׳" else: remain_str = " — כמעט סיימנו!" progress(pct, desc=f"🎙️ {int(seg.end)}s / {int(total_dur)}s — עברו {em:02d}:{es:02d}{remain_str}") # המרת faster-whisper segments לפורמט הקיים של האפליקציה segments = [] for seg in raw_segments: words = [] if seg.words: for w in seg.words: words.append({"word": w.word, "start": w.start, "end": w.end}) segments.append({ "start": seg.start, "end": seg.end, "text": seg.text.strip(), "words": words, }) elapsed = int(_time.time() - _t0) em, es = divmod(elapsed, 60) progress(0.95, desc=f"✅ תמלול הסתיים תוך {em:02d}:{es:02d} — מחלק לכתוביות...") chunks = split_chunks(segments) df = chunks_to_df(chunks) status = f"✅ תמלול הושלם בהצלחה תוך {em:02d}:{es:02d}!\nנוצרו {len(chunks)} כתוביות.\n👇 גלול למטה — ערוך כתוביות ובחר סגנון, ואז לחץ צרוב." return status, df, segments, gr.update(interactive=True), gr.update(visible=True), gr.update(visible=True), gr.update(visible=False) except TimeoutError: # ─── timeout פג — הודעה ברורה ומנחה ──────────────────────────── return ( f"⏰ התמלול לקח יותר מ-{MAX_TRANSCRIBE_SECONDS // 60} דקות ונעצר.\n" f"💡 נסה:\n" f" • לחתוך קטע קצר יותר (✂️ הגדרות מתקדמות)\n" f" • לעבור למודל 'small' או 'base' — מהירים יותר\n" f" • לדחוס את קובץ הוידאו לפני העלאה", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) ) except Exception as e: err_msg = str(e) if "ffmpeg" in err_msg.lower(): friendly = "שגיאה בחילוץ האודיו. ודא שהקובץ תקין ובפורמט MP4 או MOV." elif "memory" in err_msg.lower(): friendly = "אזל הזיכרון. נסה מודל קטן יותר (small או base)." else: friendly = f"שגיאה בתמלול. פרטים: {err_msg[:200]}" return f"❌ {friendly}", empty_df(), [], gr.update(interactive=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False) finally: # ─── משחררים תור — תמיד, בכל מצב ────────────────────────────── _transcribe_lock.release() shutil.rmtree(workdir, ignore_errors=True) def do_burn(video_path, df, font_name, hook_size, body_size, hook_hex, body_hex, karaoke_hex, outline_size, position, hook_lines, karaoke_mode, segments_state, progress=gr.Progress()): try: df_empty = df is None or (hasattr(df, "__len__") and len(df) == 0) except Exception: df_empty = True # btn_done = הכפתור חוזר לפעולה עם הטקסט המקורי btn_done = gr.update(value="🔥 צרוב כתוביות לסרטון", interactive=True) if video_path is None: return None, "❌ לא נבחר קובץ וידאו. העלה קובץ MP4 ולחץ 'תמלול' תחילה.", gr.update(visible=False), btn_done, gr.update(visible=False) if df_empty and not karaoke_mode: return None, "❌ אין כתוביות. לחץ 'תמלול' תחילה ואז חזור לצרוב.", gr.update(visible=False), btn_done, gr.update(visible=False) # ─── בדיקת תור צריבה ──────────────────────────────────────────────── if not _burn_lock.acquire(blocking=False): return ( None, "⏳ המערכת עסוקה בצריבה של משתמש אחר.\nאנא המתן כדקה ונסה שוב.", gr.update(visible=False), gr.update(value="🔥 צרוב כתוביות לסרטון", interactive=True), gr.update(visible=False) ) workdir = Path(tempfile.mkdtemp()) video = Path(video_path) cleanup_old_outputs() try: POSITIONS = {"תחתית": 2, "מרכז": 5, "עליון": 8} style = { "font": font_name, "hook_size": int(hook_size), "body_size": int(body_size), "hook_color": hex_to_ass(hook_hex), "body_color": hex_to_ass(body_hex), "karaoke_color": hex_to_ass(karaoke_hex), "outline": int(outline_size), "shadow": 2, "alignment": POSITIONS.get(position, 2), "margin_v": 80, "hook_lines": int(hook_lines), } ass_path = workdir / "subs.ass" if karaoke_mode: progress(0.1, desc="🎵 בונה קריוקי מ-word timestamps...") kar_chunks = build_karaoke_chunks(segments_state) if not kar_chunks: return None, "❌ לא נמצאו word timestamps לקריוקי. נסה לתמלל מחדש.", gr.update(visible=False), btn_done, gr.update(visible=False) edited = df_to_chunks(df) if edited and len(edited) == len(kar_chunks): for ci, ec in enumerate(edited): if "words" in kar_chunks[ci]: old_words = [w["word"] for w in kar_chunks[ci]["words"]] new_words = ec["text"].split() if len(new_words) == len(old_words): for wi, nw in enumerate(new_words): kar_chunks[ci]["words"][wi]["word"] = fix_hebrew(nw) ass_content = build_ass_karaoke(kar_chunks, style) else: progress(0.1, desc="📝 קורא כתוביות מהטבלה...") chunks = df_to_chunks(df) if not chunks: return None, "❌ לא נמצאו כתוביות תקינות", gr.update(visible=False), btn_done, gr.update(visible=False) ass_content = build_ass_content(chunks, style) ass_path.write_text(ass_content, encoding="utf-8") progress(0.4, desc="🔥 צורב כתוביות לוידאו...") out_path = OUTPUT_DIR / f"{video.stem}_subtitled.mp4" # ── נתיב ייחודי לכל משתמש — מונע דריסה הדדית ─────────────────── # workdir הוא תיקייה זמנית ייחודית שנוצרה ב-tempfile.mkdtemp() safe_ass = workdir / "subs_burn.ass" shutil.copy2(ass_path, safe_ass) # ─── FFmpeg מואץ ───────────────────────────────────────────────── # crf 23 (במקום 18) + veryfast (במקום fast) + threads 0 # תוצאה: ~2x מהיר יותר, איכות ויזואלית זהה לעין r = subprocess.run( ["ffmpeg", "-y", "-i", str(video), "-vf", f"ass={safe_ass.as_posix()}", "-c:v", "libx264", "-crf", "23", "-preset", "veryfast", "-threads", "0", "-c:a", "copy", str(out_path)], capture_output=True ) ffmpeg_log = r.stderr.decode(errors="replace") if r.returncode != 0: # ניתוח הודעת FFmpeg וחילוץ הסיבה האמיתית log_lower = ffmpeg_log.lower() if "no space left" in log_lower: tip = "❌ אין מקום בדיסק. נסה שוב בעוד כמה דקות — המערכת מנקה קבצים ישנים." elif "invalid data" in log_lower or "moov atom" in log_lower: tip = "❌ קובץ הוידאו פגום או לא שלם.\n💡 נסה: להוריד את הסרטון מחדש, לדחוס אותו דרך WhatsApp ולהעלות שוב." elif "codec" in log_lower or "encoder" in log_lower: tip = "❌ פורמט וידאו לא נתמך.\n💡 נסה: להמיר את הקובץ ל-MP4 לפני העלאה (אפשר דרך iMovie / CapCut)." elif "subtitle" in log_lower or "ass" in log_lower: tip = "❌ שגיאה בקובץ הכתוביות.\n💡 נסה: לתמלל מחדש ואז לצרוב שוב." else: tip = "❌ שגיאה בצריבה.\n💡 נסה: לרענן את הדף, להעלות את הסרטון מחדש ולתמלל שוב." return None, tip, gr.update(visible=False), btn_done, gr.update(visible=False) progress(1.0, desc="🎉 הסתיים!") size_mb = out_path.stat().st_size / 1024 / 1024 status = f"✅ הצריבה הושלמה!\nגודל הקובץ: {size_mb:.1f}MB\n👇 לחץ להורדה למטה." return str(out_path), status, gr.update(value=str(out_path), visible=True), btn_done, gr.update(visible=True) except Exception as e: err_msg = str(e).lower() if "space" in err_msg or "disk" in err_msg: friendly = "❌ אין מקום בדיסק.\n💡 נסה שוב בעוד כמה דקות." elif "memory" in err_msg or "oom" in err_msg: friendly = "❌ אזל הזיכרון.\n💡 נסה סרטון קצר יותר או רענן את הדף." elif "ffmpeg" in err_msg: friendly = "❌ שגיאה בצריבה.\n💡 נסה: לדחוס את הסרטון ב-WhatsApp ולהעלות שוב." elif "ass" in err_msg or "subtitle" in err_msg: friendly = "❌ שגיאה בכתוביות.\n💡 תמלל מחדש ואז צרוב שוב." elif "timeout" in err_msg: friendly = "❌ הצריבה לקחה יותר מדי זמן.\n💡 נסה סרטון קצר יותר (עד 60 שניות)." else: friendly = "❌ שגיאה בצריבה.\n💡 רענן את הדף ונסה שוב." shutil.rmtree(workdir, ignore_errors=True) return None, friendly, gr.update(visible=False), btn_done, gr.update(visible=False) finally: _burn_lock.release() shutil.rmtree(workdir, ignore_errors=True) FONTS = [ "Heebo", "Rubik", "Assistant", "Noto Sans Hebrew", "Noto Serif Hebrew", "Arial", "Tahoma", "Impact", "Arial Black", "Bahnschrift", "Trebuchet MS", "Franklin Gothic Medium", ] CSS = """ @import url('https://fonts.googleapis.com/css2?family=Heebo:wght@400;700;900&family=Assistant:wght@400;700;800&family=Rubik:wght@400;700;800&display=swap'); * { box-sizing: border-box; } body, .gradio-container { background: #13131f !important; font-family: 'Heebo', sans-serif !important; direction: rtl; } footer { display: none !important; } label span, .label-wrap span, span.svelte-1gfkn6j { color: #c8c8e0 !important; font-size: 13px !important; font-weight: 700 !important; } p, span, div { color: #c8c8e0; } .hero { text-align: center; padding: 40px 20px 24px; } .hero-title { font-size: 3.2em; font-weight: 900; letter-spacing: -1px; background: linear-gradient(135deg, #f0c040 0%, #ff6b35 50%, #f0c040 100%); background-size: 200% auto; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; animation: shine 3s linear infinite; margin-bottom: 8px; } @keyframes shine { 0% { background-position: 0% center; } 100% { background-position: 200% center; } } .hero-sub { color: #666680; font-size: 1.05em; } .gr-group, .gr-accordion { background: #1a1a2e !important; border: 1px solid #2e2e4e !important; border-radius: 14px !important; } button.primary { background: linear-gradient(135deg, #f0c040, #e08020) !important; color: #000 !important; font-weight: 900 !important; font-size: 16px !important; border: none !important; border-radius: 10px !important; box-shadow: 0 4px 20px rgba(240,192,64,0.3) !important; transition: transform .15s, box-shadow .15s !important; } button.primary:hover { transform: translateY(-2px) !important; box-shadow: 0 6px 28px rgba(240,192,64,0.45) !important; } button.secondary { background: linear-gradient(135deg, #c03020, #e05030) !important; color: #fff !important; font-weight: 900 !important; font-size: 16px !important; border: none !important; border-radius: 10px !important; box-shadow: 0 4px 20px rgba(200,50,30,0.35) !important; transition: transform .15s, box-shadow .15s !important; } button.secondary:hover { transform: translateY(-2px) !important; box-shadow: 0 6px 28px rgba(200,50,30,0.5) !important; } input, select, textarea { background: #1e1e32 !important; border: 1px solid #3a3a5e !important; border-radius: 8px !important; color: #e8e8f8 !important; direction: rtl !important; } input[type=range] { accent-color: #f0c040 !important; } /* ══════════════════════════════════════════════ עורך כתוביות — TIMELINE EDITOR STYLE ══════════════════════════════════════════════ */ /* מעטפת הטבלה */ .table-wrap { border-radius: 14px; overflow: hidden; border: 1px solid #1e1e3a !important; box-shadow: 0 4px 24px rgba(0,0,0,0.4); } .table-wrap table { background: #09090f !important; border-radius: 0; width: 100% !important; table-layout: fixed !important; } /* כותרות עמודות */ .table-wrap thead tr { background: #0f0f1e !important; border-bottom: 2px solid #f0c040 !important; } .table-wrap th { background: transparent !important; color: #f0c040 !important; font-weight: 900 !important; font-size: 10px !important; letter-spacing: 2px; text-transform: uppercase; padding: 10px 8px !important; } /* רוחב עמודות: זמן קטן, טקסט גדול */ .table-wrap th:nth-child(1), .table-wrap th:nth-child(2) { width: 90px !important; } .table-wrap td:nth-child(1), .table-wrap td:nth-child(2) { width: 90px !important; font-family: "Courier New", monospace !important; font-size: 12px !important; color: #6666aa !important; letter-spacing: 0.5px; padding: 10px 6px !important; text-align: center !important; border-left: 1px solid #1a1a2e !important; } /* עמודת הטקסט — הכוכב של המופע */ .table-wrap td:nth-child(3) { direction: rtl !important; text-align: right !important; font-size: 14px !important; font-weight: 700 !important; color: #e8e8ff !important; font-family: "Heebo", "Arial", sans-serif !important; padding: 10px 14px !important; line-height: 1.5 !important; } /* שורות */ .table-wrap td { border-bottom: 1px solid #13131f !important; transition: background .1s; } .table-wrap tr:nth-child(even) td { background: rgba(255,255,255,0.015) !important; } .table-wrap tr:hover td { background: rgba(240,192,64,0.06) !important; } .table-wrap tr:hover td:nth-child(1), .table-wrap tr:hover td:nth-child(2) { color: #aaaadd !important; } .table-wrap tr:hover td:nth-child(3) { color: #f0c040 !important; } /* שורה בעריכה — הדגשה סגולה */ .table-wrap tr:focus-within td { background: rgba(100,80,220,0.1) !important; } .table-wrap tr:focus-within td:nth-child(3) { color: #fff !important; } /* תיבת עריכה בתוך התא */ .table-wrap input[type="text"], .table-wrap textarea { background: #1a1a30 !important; border: 1px solid #5544cc !important; border-radius: 6px !important; color: #fff !important; font-family: "Heebo", sans-serif !important; direction: rtl !important; } /* ── פריוויו — מסגרת טלפון ── */ .karaoke-box { background: linear-gradient(135deg, #1a1228, #0d1020) !important; border: 1px solid #3a2060 !important; border-radius: 12px !important; padding: 16px !important; } label span { color: #b0b0d0 !important; font-size: 13px !important; font-weight: 700 !important; } .status-box textarea { font-family: 'Heebo', sans-serif !important; font-size: 14px !important; color: #d0d0f0 !important; border-color: #3a3a5e !important; background: #1e1e32 !important; } #download-btn { background: linear-gradient(135deg, #30c070, #20a050) !important; color: #fff !important; font-weight: 900 !important; border-radius: 12px !important; font-size: 17px !important; margin-top: 8px !important; box-shadow: 0 4px 20px rgba(48,192,112,0.4) !important; width: 100% !important; } #download-btn:hover { transform: translateY(-2px) !important; box-shadow: 0 6px 28px rgba(48,192,112,0.6) !important; } #reset-btn { background: transparent !important; border: 1px solid #3a3a5e !important; color: #888 !important; font-size: 13px !important; border-radius: 8px !important; margin-top: 12px !important; transition: all .2s !important; } #reset-btn:hover { border-color: #f0c040 !important; color: #f0c040 !important; } /* ── tooltip לכפתורי סגנון ── */ .style-btn-wrap { position: relative; flex: 1; } .style-btn-wrap .style-tooltip { display: none; position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); background: #1a1a2e; border: 1px solid #f0c040; border-radius: 8px; padding: 7px 12px; color: #e8e8f0; font-size: 12px; white-space: nowrap; z-index: 999; pointer-events: none; box-shadow: 0 4px 16px rgba(0,0,0,0.5); } .style-btn-wrap:hover .style-tooltip { display: block; } .style-btn-wrap .style-tooltip::after { content: ""; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 6px solid transparent; border-top-color: #f0c040; } /* ── סרטון מוכן — גובה מוגבל ── */ .video-out-wrap video { max-height: 400px !important; width: auto !important; max-width: 100% !important; margin: 0 auto !important; display: block !important; border-radius: 10px !important; } .video-out-wrap { max-width: 360px !important; margin: 0 auto !important; } .export-row button { flex: 1; border: 1px solid #3a3a5e !important; background: #1a1a2e !important; color: #c8c8e0 !important; font-weight: 700 !important; border-radius: 8px !important; transition: all .2s !important; } .export-row button:hover { border-color: #f0c040 !important; color: #f0c040 !important; } /* ── פריוויו — מסגרת טלפון 9:16 ── */ .preview-wrap { background: #0a0a14; border-radius: 16px; border: 1px solid #2a2a4a; padding: 0; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.6); } .preview-phone-bar { background: #111118; padding: 8px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #1a1a2a; } .preview-phone-bar span { color: #555577; font-size: 10px; font-family: monospace; letter-spacing: 1px; } .preview-phone-dot { width: 8px; height: 8px; border-radius: 50%; background: #ff5f57; box-shadow: 14px 0 0 #febc2e, 28px 0 0 #28c840; display: inline-block; } .preview-img-wrap img { display: block; width: 100% !important; border-radius: 0 !important; } .preview-footer { background: #111118; padding: 8px 16px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #1a1a2a; } .preview-footer span { color: #333355; font-size: 10px; font-family: monospace; } .preview-badge { background: rgba(240,192,64,0.12); border: 1px solid rgba(240,192,64,0.3); color: #f0c040; font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 20px; letter-spacing: 1px; } .step-header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; margin-bottom: 16px; background: linear-gradient(135deg, #1a1a2e, #16213e); border-radius: 10px; border-right: 4px solid #f0c040; } .step-num { width: 32px; height: 32px; background: #f0c040; color: #000; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 16px; flex-shrink: 0; } .step-title { font-size: 18px; font-weight: 900; color: #e8e8f0; } .tips-bar { text-align: center; color: #444460; font-size: 13px; padding: 16px; border-top: 1px solid #1a1a28; margin-top: 8px; } .tips-bar b { color: #f0c040; } /* ── Radio buttons — מראים בחירה בצהוב ── */ input[type="radio"] { appearance: none !important; -webkit-appearance: none !important; width: 20px !important; height: 20px !important; border: 2px solid #444466 !important; border-radius: 50% !important; background: #1a1a2e !important; cursor: pointer !important; transition: all .15s !important; flex-shrink: 0 !important; margin-left: 6px !important; } input[type="radio"]:checked { border-color: #f0c040 !important; background: #f0c040 !important; box-shadow: 0 0 0 3px rgba(240,192,64,0.25) !important; } input[type="radio"]:hover { border-color: #aaaacc !important; } /* תווית ליד radio — צהובה כשנבחרה */ label:has(input[type="radio"]:checked) span, label:has(input[type="radio"]:checked) { color: #f0c040 !important; } /* ── Checkbox — מראה סימון בצהוב ── */ input[type="checkbox"] { appearance: none !important; -webkit-appearance: none !important; width: 20px !important; height: 20px !important; border: 2px solid #444466 !important; border-radius: 5px !important; background: #1a1a2e !important; cursor: pointer !important; transition: all .15s !important; flex-shrink: 0 !important; position: relative !important; } input[type="checkbox"]:checked { border-color: #f0c040 !important; background: #f0c040 !important; box-shadow: 0 0 0 3px rgba(240,192,64,0.25) !important; } input[type="checkbox"]:checked::after { content: "✓" !important; position: absolute !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%) !important; color: #000 !important; font-size: 13px !important; font-weight: 900 !important; line-height: 1 !important; } input[type="checkbox"]:hover { border-color: #aaaacc !important; } /* תווית ליד checkbox — צהובה כשמסומנת */ label:has(input[type="checkbox"]:checked) span, label:has(input[type="checkbox"]:checked) { color: #f0c040 !important; } .time-estimate { background: linear-gradient(135deg, #1a2a1a, #0d1a0d); border: 1px solid #2a4a2a; border-radius: 10px; padding: 10px 16px; margin: 8px 0; color: #80d080 !important; font-size: 14px !important; font-weight: 700; text-align: center; } /* ── waveform ── */ .waveform-wrap { border-radius: 10px; overflow: hidden; border: 1px solid #1a1a3a !important; margin-bottom: 10px !important; background: #090912; box-shadow: 0 2px 16px rgba(0,0,0,0.5); } .waveform-wrap img { display: block; width: 100% !important; height: 140px !important; object-fit: fill !important; border-radius: 0 !important; image-rendering: pixelated; } /* ══════════════════════════════════════════ מובייל — עד 768px ══════════════════════════════════════════ */ @media (max-width: 768px) { /* כותרת ראשית — קטנה יותר */ .hero-title { font-size: 2.2em !important; } .hero-sub { font-size: 0.9em !important; } .hero { padding: 24px 12px 16px !important; } /* שלב 1: עמודות → שורות (אחת מתחת לשנייה) */ .gradio-row { flex-direction: column !important; } .gradio-column { width: 100% !important; min-width: 0 !important; } /* כפתורים — גדולים יותר לאצבע */ button { min-height: 52px !important; font-size: 16px !important; } button.primary { font-size: 17px !important; min-height: 56px !important; } button.secondary { font-size: 15px !important; min-height: 52px !important; } /* ColorPicker — גובה מספיק לאצבע */ input[type="color"] { height: 48px !important; min-width: 48px !important; } /* Sliders — גדולים יותר */ input[type="range"] { height: 28px !important; } /* כותרות שלבים */ .step-title { font-size: 15px !important; } .step-header { padding: 10px 14px !important; } /* שלב 3: טבלה + פריוויו → אחד מתחת לשני */ /* הפריוויו עובר לתחתית */ .preview-wrap { margin-top: 16px; } /* טבלת כתוביות — עמודות זמן קטנות יותר */ .table-wrap th:nth-child(1), .table-wrap th:nth-child(2) { width: 70px !important; } .table-wrap td:nth-child(1), .table-wrap td:nth-child(2) { width: 70px !important; font-size: 10px !important; padding: 8px 4px !important; } .table-wrap td:nth-child(3) { font-size: 13px !important; padding: 8px 10px !important; } /* waveform — גובה קצת קטן יותר */ .waveform-wrap img { height: 100px !important; } /* accordion — יותר מרווח לאצבע */ .gr-accordion > .label-wrap { padding: 14px 16px !important; min-height: 48px !important; } /* status box */ .status-box textarea { font-size: 13px !important; } /* כפתור הורדה — רחב ובולט */ #download-btn { font-size: 18px !important; min-height: 60px !important; } #reset-btn { min-height: 44px !important; } /* tips bar — מוסתר במובייל, חוסך מקום */ .tips-bar { display: none !important; } } /* ══════════════════════════════════════════ מובייל קטן במיוחד — עד 480px ══════════════════════════════════════════ */ @media (max-width: 480px) { .hero-title { font-size: 1.8em !important; } .table-wrap td:nth-child(1), .table-wrap td:nth-child(2) { width: 58px !important; font-size: 9px !important; } .table-wrap td:nth-child(3) { font-size: 12px !important; } } """ PWA_HTML = """ """ HEADER_HTML = """
HebSub ⚡
תמלול עברית אוטומטי • כתוביות TikTok • צריבה ישירה לסרטון
📹 העלה  →  🎙️ תמלל  →  🔥 צרוב  —  הכל ב-2 דקות
🔥 ניתוח וויראליות אוטומטי
✨ 5 סגנונות קסם מוכנים
⚡ תמלול מהיר עם faster-whisper
🎬 פריוויו אמיתי לפני הצריבה
""" TIPS_HTML = """
Heebo לטקסט נקי  •  Rubik לסגנון מודרני  •  Pop-up לסגנון TikTok  •  מודל medium — מהיר ומדויק
""" def split_row(df, row_num_str: str): """מפצל שורה אחת לשתיים — חולק את הטקסט ואת הזמן שווה בשווה.""" try: row_num = int(str(row_num_str).strip()) - 1 # 1-based → 0-based chunks = df_to_chunks(df) if not chunks or row_num < 0 or row_num >= len(chunks): return df, f"❌ שורה {row_num+1} לא קיימת. יש {len(chunks)} שורות." chunk = chunks[row_num] words = chunk["text"].split() if len(words) < 2: return df, "❌ השורה קצרה מדי לפיצול (מילה אחת בלבד)." mid = len(words) // 2 mid_time = chunk["start"] + (chunk["end"] - chunk["start"]) / 2 first = {"start": chunk["start"], "end": mid_time, "text": " ".join(words[:mid])} second = {"start": mid_time, "end": chunk["end"], "text": " ".join(words[mid:])} new_chunks = chunks[:row_num] + [first, second] + chunks[row_num+1:] return chunks_to_df(new_chunks), f"✅ שורה {row_num+1} פוצלה לשתיים." except Exception as e: return df, f"❌ שגיאה: {e}" def merge_rows(df, row_num_str: str): """מאחד שורה עם השורה שאחריה — מחבר טקסט ולוקח את זמן ההתחלה/סיום.""" try: row_num = int(str(row_num_str).strip()) - 1 # 1-based → 0-based chunks = df_to_chunks(df) if not chunks or row_num < 0 or row_num >= len(chunks) - 1: return df, f"❌ שורה {row_num+1} לא ניתן לאחד (לא קיימת או אחרונה)." a = chunks[row_num] b = chunks[row_num + 1] merged = {"start": a["start"], "end": b["end"], "text": a["text"] + " " + b["text"]} new_chunks = chunks[:row_num] + [merged] + chunks[row_num+2:] return chunks_to_df(new_chunks), f"✅ שורות {row_num+1} ו-{row_num+2} אוחדו." except Exception as e: return df, f"❌ שגיאה: {e}" def generate_waveform(audio_path: str, segments: list, total_dur: float) -> object: """ יוצר תמונת waveform במהירות גבוהה עם numpy. במקום לצייר pixel-by-pixel (איטי), בונים מערך numpy שלם ומעבירים ל-Pillow. מהיר פי ~10 מהגרסה הקודמת. """ try: from PIL import Image, ImageDraw, ImageFont import wave as _wave import numpy as np # ── חילוץ אודיו בדגימה נמוכה — 4000Hz מספיק לוויזואליזציה ─────── tmp_wav = Path(tempfile.mktemp(suffix=".wav")) r = subprocess.run( ["ffmpeg", "-y", "-i", str(audio_path), "-ar", "4000", "-ac", "1", "-c:a", "pcm_s16le", str(tmp_wav)], capture_output=True ) if r.returncode != 0 or not tmp_wav.exists(): return None with _wave.open(str(tmp_wav), "rb") as wf: raw = wf.readframes(wf.getnframes()) tmp_wav.unlink(missing_ok=True) samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32) if len(samples) == 0: return None # ── ממדים ──────────────────────────────────────────────────────── W, H = 900, 140 WAVE_H = 80 AXIS_H = 30 PAD = 10 cy = PAD + WAVE_H // 2 # ── בניית מערך הפיקסלים עם numpy (במקום לולאות Python) ────────── # canvas שחור-כחול canvas = np.zeros((H, W, 3), dtype=np.uint8) canvas[:, :] = [9, 9, 18] # רקע מדורג — וקטורי לחלוטין ys = np.arange(H, dtype=np.float32) shade = (8 + (ys / H) * 6).astype(np.uint8) canvas[:, :, 0] = shade[:, None] canvas[:, :, 1] = shade[:, None] canvas[:, :, 2] = np.clip(shade[:, None].astype(np.int16) + 12, 0, 255).astype(np.uint8) # ── חישוב peak לכל עמודה בוקטור אחד ───────────────────────────── n = len(samples) step = max(1, n // W) # חיתוך לאורך שמתחלק ב-step trim = (n // step) * step blocks = samples[:trim].reshape(-1, step) # (blocks, step) peaks = np.abs(blocks).max(axis=1) # peak לכל block # pad / crop ל-W עמודות if len(peaks) < W: peaks = np.pad(peaks, (0, W - len(peaks))) else: peaks = peaks[:W] max_amp = peaks.max() or 1.0 peaks = peaks / max_amp # normalize 0..1 bar_heights = np.maximum(2, (peaks * (WAVE_H / 2) * 0.92).astype(int)) # ── ציור הגל — לולאה רק על W עמודות, פנימה numpy slice ────────── for xi, bar_h in enumerate(bar_heights): # גרדיאנט: t=0 בשיא (צבע כחול כהה), t=1 בבסיס (כחול-סגול) dy_arr = np.arange(bar_h, dtype=np.float32) t = dy_arr / max(bar_h, 1) r_arr = (60 + t * 80).astype(np.uint8) g_arr = (40 + t * 30).astype(np.uint8) b_arr = (180 + t * 60).astype(np.uint8) # חצי עליון y_ups = cy - dy_arr.astype(int) mask = (y_ups >= PAD) & (y_ups < PAD + WAVE_H) canvas[y_ups[mask], xi] = np.stack([r_arr[mask], g_arr[mask], b_arr[mask]], axis=1) # חצי תחתון y_dns = cy + dy_arr.astype(int) mask = (y_dns >= PAD) & (y_dns < PAD + WAVE_H) canvas[y_dns[mask], xi] = np.stack([r_arr[mask], g_arr[mask], b_arr[mask]], axis=1) # ── קו אמצע ────────────────────────────────────────────────────── canvas[cy, :] = [40, 40, 80] # ── הדגשות כתוביות — numpy slice, אפס לולאות פיקסל ───────────── for seg in segments: x0 = int((seg["start"] / max(total_dur, 1)) * W) x1 = int((seg["end"] / max(total_dur, 1)) * W) x1 = max(x1, x0 + 3) x1 = min(x1, W) if x0 >= x1: continue region = canvas[PAD:PAD + WAVE_H, x0:x1].astype(np.float32) region[:, :, 0] = np.clip(region[:, :, 0] * 0.55 + 200, 0, 255) region[:, :, 1] = np.clip(region[:, :, 1] * 0.55 + 160, 0, 255) region[:, :, 2] = np.clip(region[:, :, 2] * 0.20, 0, 255) canvas[PAD:PAD + WAVE_H, x0:x1] = region.astype(np.uint8) # קו אנכי צהוב בתחילת כל כתובית if x0 < W: canvas[PAD:PAD + WAVE_H, x0] = [255, 210, 0] img = Image.fromarray(canvas, "RGB") draw = ImageDraw.Draw(img) # ── ציר זמן ────────────────────────────────────────────────────── axis_y = PAD + WAVE_H + 2 draw.rectangle([(0, axis_y), (W, axis_y + AXIS_H)], fill=(6, 6, 14)) draw.line([(0, axis_y), (W, axis_y)], fill=(30, 30, 60), width=1) try: font_sm = ImageFont.truetype( "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 10) except Exception: font_sm = ImageFont.load_default() tick_interval = 5 if total_dur <= 60 else 10 t_mark = 0 while t_mark <= total_dur: xm = int((t_mark / max(total_dur, 1)) * W) draw.line([(xm, axis_y), (xm, axis_y + 6)], fill=(80, 80, 120), width=1) m, s = divmod(int(t_mark), 60) draw.text((xm + 2, axis_y + 7), f"{m}:{s:02d}", font=font_sm, fill=(100, 100, 160)) t_mark += tick_interval # ── תווית WAVEFORM ──────────────────────────────────────────────── label_y = axis_y + AXIS_H + 2 draw.rectangle([(0, label_y), (W, H)], fill=(6, 6, 14)) try: font_label = ImageFont.truetype( "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 9) except Exception: font_label = ImageFont.load_default() draw.text((6, label_y + 4), "▶ WAVEFORM", font=font_label, fill=(50, 50, 100)) draw.text((W - 80, label_y + 4), f"{total_dur:.1f}s total", font=font_label, fill=(50, 50, 100)) # הדגשה צהובה בלגנד draw.rectangle([(W//2 - 60, label_y + 5), (W//2 - 50, label_y + 13)], fill=(60, 48, 10)) draw.text((W//2 - 46, label_y + 4), "= קטע כתובית", font=font_label, fill=(80, 70, 30)) return img except Exception: return None def generate_preview(font_name, hook_size, body_size, hook_hex, body_hex, outline_size, position, hook_lines, subtitle_table, video_path=None, karaoke_mode=False): """ תצוגה מקדימה אמיתית: - שולף frame מאמצע הסרטון עם FFmpeg - מצייר את הכתוביות האמצעיות עליו - אם אין סרטון — חוזר לרקע שחור גנרי """ try: from PIL import Image, ImageDraw, ImageFont import io chunks = df_to_chunks(subtitle_table) if subtitle_table is not None else [] # ── בחירת כתוביות לתצוגה: האמצעיות בסרטון ────────────────────── if len(chunks) == 0: mid_chunks = [] elif len(chunks) == 1: mid_chunks = [chunks[0]] else: mid_i = len(chunks) // 2 mid_chunks = chunks[mid_i: mid_i + 2] hook_text = mid_chunks[0]["text"] if len(mid_chunks) > 0 else "שלום עולם" body_text = mid_chunks[1]["text"] if len(mid_chunks) > 1 else "" W, H = 540, 960 # ── שליפת frame אמיתי מהסרטון ─────────────────────────────────── img = None if video_path is not None: try: dur = get_video_duration(video_path) if dur > 0: # frame מהכתובית האמצעית עצמה (לא אמצע הסרטון בזמן) seek_t = mid_chunks[0]["start"] + (mid_chunks[0]["end"] - mid_chunks[0]["start"]) / 2 if mid_chunks else dur / 2 seek_t = min(seek_t, dur - 0.1) r = subprocess.run( ["ffmpeg", "-y", "-ss", f"{seek_t:.2f}", "-i", str(video_path), "-vframes", "1", "-vf", f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H}", "-f", "image2pipe", "-vcodec", "png", "-"], capture_output=True, timeout=10 ) if r.returncode == 0 and r.stdout: img = Image.open(io.BytesIO(r.stdout)).convert("RGB") except Exception: img = None # ── fallback: רקע כהה גנרי אם אין סרטון ──────────────────────── if img is None: img = Image.new("RGB", (W, H), (15, 15, 25)) # ── כלי ציור ────────────────────────────────────────────────────── # שכבת כהייה עדינה כדי שהכתוביות יהיו קריאות על כל רקע overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0)) ov_draw = ImageDraw.Draw(overlay) draw = ImageDraw.Draw(img) hook_rgb = tuple(color_to_rgb(hook_hex)) body_rgb = tuple(color_to_rgb(body_hex)) ol = max(1, int(outline_size)) def find_best_font(size): candidates = [ "/usr/share/fonts/truetype/noto/NotoSansHebrew-Bold.ttf", "/usr/share/fonts/truetype/noto/NotoSerifHebrew-Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/truetype/freefont/FreeSansBold.ttf", "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", ] for path in candidates: try: return ImageFont.truetype(path, size), Path(path).stem except Exception: continue return ImageFont.load_default(), "default" hook_font, font_used = find_best_font(int(hook_size * 0.55)) body_font, _ = find_best_font(int(body_size * 0.55)) def draw_outlined(text, font, color, x, y): # outline שחור סביב הטקסט — קריא על כל רקע for dx in range(-ol, ol + 1): for dy in range(-ol, ol + 1): if dx or dy: draw.text((x + dx, y + dy), text, font=font, fill=(0, 0, 0), anchor="mm") draw.text((x, y), text, font=font, fill=color, anchor="mm") # ── מיקום כתוביות ───────────────────────────────────────────────── POSITIONS = {"תחתית": 2, "מרכז": 5, "עליון": 8} align = POSITIONS.get(position, 2) if align == 8: y_hook = int(H * 0.15) y_body = int(H * 0.25) elif align == 5: y_hook = int(H * 0.45) y_body = int(H * 0.57) else: y_hook = int(H * 0.75) y_body = int(H * 0.85) x = W // 2 if karaoke_mode: # ── מצב Popup: מילה אחת גדולה ובולטת במרכז ───────────────── # לוקחים את המילה הראשונה מהכתובית האמצעית popup_word = hook_text.split()[0] if hook_text.split() else hook_text popup_font, _ = find_best_font(int(hook_size * 0.85)) # גדול יותר מרגיל popup_rgb = tuple(color_to_rgb(hook_hex)) draw_outlined(popup_word, popup_font, popup_rgb, x, y_hook) # תווית הסבר קטנה info_font, _ = find_best_font(11) draw.text((x, y_hook + int(hook_size * 0.55) + 12), "← מצב Pop-up: מילה אחת בכל פעם", font=info_font, fill=(120, 100, 200), anchor="mm") else: draw_outlined(hook_text, hook_font, hook_rgb, x, y_hook) if body_text: draw_outlined(body_text, body_font, body_rgb, x, y_body) # ── תווית "REAL PREVIEW" בפינה ──────────────────────────────────── badge_font, _ = find_best_font(11) if karaoke_mode: label = "✨ POPUP MODE" elif video_path: label = "📍 FRAME אמיתי" else: label = "🎨 PREVIEW גנרי" # רקע כהה לתווית bbox = draw.textbbox((6, H - 22), label, font=badge_font) draw.rectangle([bbox[0]-4, bbox[1]-3, bbox[2]+4, bbox[3]+3], fill=(0, 0, 0, 180)) draw.text((6, H - 22), label, font=badge_font, fill=(180, 180, 220)) return img except ImportError: return None except Exception: return None def get_video_duration(video_path) -> float: if video_path is None: return 0.0 try: r = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(video_path)], capture_output=True, text=True ) return float(r.stdout.strip()) except Exception: return 0.0 def on_video_upload(video_path): if video_path is None: return gr.update(), gr.update(), gr.update(value="", visible=False), gr.update(interactive=False) # ── בדיקת גודל קובץ מיד אחרי העלאה ────────────────────────────────── file_mb = Path(video_path).stat().st_size / 1024 / 1024 if file_mb > MAX_FILE_MB: warning = ( f'
' f'❌ הקובץ גדול מדי — {file_mb:.0f}MB (מגבלה: {MAX_FILE_MB}MB)
' f'💡 דחוס את הוידאו ב-WhatsApp או ב-CapCut ונסה שוב.' f'
' ) return gr.update(), gr.update(), gr.update(value=warning, visible=True), gr.update(interactive=False) dur = get_video_duration(video_path) if dur <= 0: return gr.update(), gr.update(), gr.update(value="", visible=False), gr.update(interactive=True) # ── בדיקת אורך וידאו ───────────────────────────────────────────────── if dur > MAX_DURATION_SECS: warning = ( f'
' f'⚠️ הסרטון ארוך מדי — {dur:.0f} שניות (מגבלה: {MAX_DURATION_SECS} שניות)
' f'💡 השתמש בחיתוך למטה כדי לבחור קטע של עד {MAX_DURATION_SECS} שניות.' f'
' ) return ( gr.update(maximum=dur, value=0.0, label=f"התחלה (0 — {dur:.0f}s)"), gr.update(maximum=dur, value=dur, label=f"סיום ({dur:.0f}s)"), gr.update(value=warning, visible=True), gr.update(interactive=True), # עדיין מאפשר — אפשר לחתוך ) # ── הכל תקין ───────────────────────────────────────────────────────── return ( gr.update(maximum=dur, value=0.0, label=f"התחלה (0 — {dur:.0f}s)"), gr.update(maximum=dur, value=dur, label=f"סיום ({dur:.0f}s)"), gr.update(value="", visible=False), gr.update(interactive=True), ) def export_srt(subtitle_table) -> str: chunks = df_to_chunks(subtitle_table) if subtitle_table is not None else [] if not chunks: return None lines = [] for i, c in enumerate(chunks, 1): lines.append(f"{i}\n{ts_to_str(c['start'])} --> {ts_to_str(c['end'])}\n{c['text']}\n") path = OUTPUT_DIR / "subtitles.srt" path.write_text("\n".join(lines), encoding="utf-8") return str(path) def export_ass(subtitle_table, font_name, hook_size, body_size, hook_hex, body_hex, outline_size, position, hook_lines) -> str: chunks = df_to_chunks(subtitle_table) if subtitle_table is not None else [] if not chunks: return None POSITIONS = {"תחתית": 2, "מרכז": 5, "עליון": 8} style = { "font": font_name, "hook_size": int(hook_size), "body_size": int(body_size), "hook_color": hex_to_ass(hook_hex), "body_color": hex_to_ass(body_hex), "outline": int(outline_size), "shadow": 2, "alignment": POSITIONS.get(position, 2), "margin_v": 80, "hook_lines": int(hook_lines), } ass_content = build_ass_content(chunks, style) path = OUTPUT_DIR / "subtitles.ass" path.write_text(ass_content, encoding="utf-8") return str(path) # ─── פונקציה חדשה: מחשבת זמן משוער ומציגה למשתמש לפני לחיצה ────────────── def update_time_estimate(video_path, model_size, trim_start, trim_end): """מציגה למשתמש זמן משוער מיד אחרי העלאת הוידאו — לפני לחיצה על תמלול.""" if video_path is None: return gr.update(value="", visible=False) dur = get_video_duration(video_path) if dur <= 0: return gr.update(value="", visible=False) effective_end = min(trim_end, dur) if trim_end > 0 and trim_end < dur else dur effective_start = max(0, trim_start) effective_dur = max(1, effective_end - effective_start) t_min, t_mid, t_max = estimate_transcribe_time(effective_dur, model_size) vid_m, vid_s = divmod(int(effective_dur), 60) vid_str = f"{vid_m}:{vid_s:02d}" if vid_m > 0 else f"{vid_s} שניות" if t_mid < 60: time_str = f"~{t_min}–{t_max} שניות" else: max_m, max_s = divmod(t_max, 60) mid_m, mid_s = divmod(t_mid, 60) time_str = f"~{mid_m}:{mid_s:02d} עד {max_m}:{max_s:02d} דקות" html = f'
⏱️ הוידאו: {vid_str} • זמן תמלול משוער: {time_str} (מודל {model_size})
' return gr.update(value=html, visible=True) # ─── סגנונות קסם — תבניות מוכנות מראש ─────────────────────────────────── MAGIC_STYLES = { "🔥 הורמוזי": { "font": "Impact", "hook_color": "#FFFF00", "body_color": "#FFFFFF", "hook_size": 95, "body_size": 72, "outline": 4, "position": "תחתית", "hook_lines": 2, "desc": "צהוב + לבן, גדול ובולט — TikTok קלאסי" }, "🎬 ולוגר": { "font": "Heebo", "hook_color": "#FFFFFF", "body_color": "#CCCCCC", "hook_size": 80, "body_size": 60, "outline": 3, "position": "תחתית", "hook_lines": 2, "desc": "לבן נקי — סגנון YouTube / vlog" }, "💜 דרמטי": { "font": "Rubik", "hook_color": "#FF44FF", "body_color": "#FFFFFF", "hook_size": 90, "body_size": 65, "outline": 4, "position": "מרכז", "hook_lines": 1, "desc": "סגול ורוד — בולט ורגשי" }, "🤍 מינימליסטי": { "font": "Assistant", "hook_color": "#FFFFFF", "body_color": "#DDDDDD", "hook_size": 68, "body_size": 52, "outline": 2, "position": "תחתית", "hook_lines": 2, "desc": "קטן ועדין — לתוכן רציני" }, "🔴 אש": { "font": "Impact", "hook_color": "#FF4400", "body_color": "#FFAA00", "hook_size": 100, "body_size": 75, "outline": 5, "position": "תחתית", "hook_lines": 2, "desc": "כתום-אדום — אנרגטי ונועז" }, } # ─── מנתח וויראליות — חינמי, ללא API ──────────────────────────────────── HOOK_WORDS = [ "סוד", "חינם", "מטורף", "לא תאמינו", "שגיאה", "טיפ", "טריק", "הפתעה", "מהיר", "קל", "פשוט", "חשוב", "דחוף", "בלעדי", "ראשון", "אחרון", "כסף", "רווח", "הצלחה", "כישלון", "אמת", "שקר", "נסתר", "גלוי", "למה", "איך", "מתי", "מה", "מי", "האמת", "הסוד", "הטעות", ] def analyze_virality(segments: list, total_dur: float) -> str: """ מנתח את הסרטון ונותן ציון וויראליות + טיפים. מחזיר HTML מעוצב לתצוגה. """ if not segments: return "" score = 50 # ציון בסיס tips_good = [] tips_bad = [] all_text = " ".join(s.get("text", "") for s in segments) first_text = segments[0].get("text", "") if segments else "" last_text = segments[-1].get("text", "") if segments else "" # ── 1. האם השורה הראשונה חזקה? ────────────────────────────────────── has_hook = any(w in first_text for w in HOOK_WORDS) has_question = "?" in first_text or any(q in first_text for q in ["למה", "איך", "מה ", "מי "]) if has_hook: score += 15 tips_good.append("השורה הראשונה מכילה מילת עצירה חזקה 🎯") elif has_question: score += 10 tips_good.append("השורה הראשונה פותחת בשאלה — טוב לעצירת גלילה ✅") else: score -= 10 tips_bad.append("השורה הראשונה חלשה — נסה לפתוח עם שאלה או מילה מפתיעה") # ── 2. אורך הסרטון ─────────────────────────────────────────────────── if total_dur <= 15: score += 15 tips_good.append("סרטון קצר מאוד — מושלם לTikTok ✅") elif total_dur <= 30: score += 10 tips_good.append("אורך אידיאלי לReels ✅") elif total_dur <= 60: score += 0 tips_bad.append("סרטון בינוני — רוב הצופים יברחו אחרי 30 שניות") else: score -= 15 tips_bad.append("סרטון ארוך מדי — שקול לחתוך לקטע הכי חזק") # ── 3. האם יש קריאה לפעולה בסוף? ──────────────────────────────────── cta_words = ["עקוב", "שתף", "לייק", "תגובה", "שמור", "שלח", "קנה", "הירשם", "לחץ"] has_cta = any(w in last_text for w in cta_words) if has_cta: score += 10 tips_good.append("יש קריאה לפעולה בסוף ✅") else: tips_bad.append("חסרה קריאה לפעולה — הוסף 'שתף אם עזר' / 'עקוב לעוד'") # ── 4. ממוצע מילים לכתובית ─────────────────────────────────────────── total_words = len(all_text.split()) avg_words = total_words / max(len(segments), 1) if avg_words <= 4: score += 5 tips_good.append("כתוביות קצרות וקריאות ✅") elif avg_words > 7: score -= 5 tips_bad.append("כתוביות ארוכות מדי — קשה לקרוא בגלילה") # ── 5. מגוון — האם יש מילות מפתח לאורך הסרטון? ───────────────────── hook_count = sum(1 for w in HOOK_WORDS if w in all_text) if hook_count >= 3: score += 5 tips_good.append("תוכן עשיר במילות מפתח לאורך הסרטון ✅") score = max(0, min(100, score)) # ── צבע ציון ───────────────────────────────────────────────────────── if score >= 75: score_color = "#44ff88" score_label = "וויראלי 🔥" elif score >= 55: score_color = "#f0c040" score_label = "פוטנציאל טוב ⚡" else: score_color = "#ff6666" score_label = "צריך שיפור 📈" good_html = "".join(f'
✅ {t}
' for t in tips_good) bad_html = "".join(f'
💡 {t}
' for t in tips_bad) return f"""
{score}
{score_label}
ציון וויראליות
{total_dur:.0f}s • {len(segments)} קטעים • {total_words} מילים
{good_html} {bad_html}
""" # ─── ממשק Gradio ───────────────────────────────────────────────────────── with gr.Blocks( title="HebSub ⚡", theme=gr.themes.Base(primary_hue="yellow", neutral_hue="slate", font=gr.themes.GoogleFont("Heebo")), css=CSS ) as demo: gr.HTML(PWA_HTML) gr.HTML(HEADER_HTML) with gr.Group() as step1_group: gr.HTML('
1העלה סרטון ותמלל
') with gr.Row(): with gr.Column(scale=1): video_in = gr.Video(label="📹 העלה סרטון MP4 / MOV", height=400, elem_classes="video-out-wrap") upload_warning = gr.HTML(value="", visible=False) gr.Markdown('מגבלה: 90 שניות • עד 500MB.') with gr.Accordion("✂️ חיתוך וידאו (אופציונלי)", open=False): gr.Markdown('לסרטונים ארוכים — חתוך לקטע הרצוי לפני תמלול.') with gr.Row(): trim_start = gr.Slider(minimum=0, maximum=600, value=0, step=1, label="התחלה", interactive=True) trim_end = gr.Slider(minimum=0, maximum=600, value=600, step=1, label="סיום", interactive=True) with gr.Accordion("⚙️ הגדרות תמלול", open=False): model_sel = gr.Dropdown( ["medium", "large-v3", "small", "base"], value="medium", label="🤖 מודל Whisper" ) with gr.Column(scale=1): # ─── תצוגת זמן משוער — חדש! ─────────────────────────── time_estimate_box = gr.HTML(value="", visible=False) transcribe_btn = gr.Button("🎙️ שלב 2 — תמלול אוטומטי", variant="primary", size="lg", interactive=False) gr.Markdown('⚡ מואץ עם faster-whisper — מהיר פי 3-4 מגרסה קודמת') status_out = gr.Textbox(label="📊 סטטוס", lines=3, interactive=False, elem_classes="status-box") segments_state = gr.State([]) with gr.Group(visible=False) as step3_group: gr.HTML('
2ערוך כתוביות (אופציונלי)
') with gr.Row(): # ── צד שמאל: הטבלה ────────────────────────────────────────── with gr.Column(scale=3): gr.HTML('''
Timeline לחץ על כל שורה לעריכה
''') waveform_img = gr.Image( label="", show_download_button=False, show_label=False, container=False, visible=False, height=140, elem_classes="waveform-wrap", ) virality_box = gr.HTML(value="", visible=False) subtitle_table = gr.Dataframe( value=empty_df(), headers=["▶ התחלה", "⏹ סיום", "כתובית"], datatype=["str", "str", "str"], col_count=(3, "fixed"), row_count=(1, "dynamic"), interactive=True, wrap=True, label="", elem_classes="table-wrap" ) karaoke_warning = gr.Markdown(value='💡 לחץ על תא כדי לערוך • שינויים ישמרו אוטומטית') # ── כלי עריכה: פיצול ואיחוד שורות ────────────────────── with gr.Row(): row_num_input = gr.Number( value=1, minimum=1, step=1, label="מספר שורה", scale=1, info="איזו שורה לפצל/לאחד?" ) split_btn = gr.Button("✂️ פצל שורה לשתיים", variant="secondary", size="sm", scale=2) merge_btn = gr.Button("🔗 איחד עם השורה הבאה", variant="secondary", size="sm", scale=2) edit_status = gr.Markdown(value="", visible=False) with gr.Row(visible=False, elem_classes="export-row") as export_row: export_srt_btn = gr.DownloadButton("💾 ייצא SRT", variant="secondary", size="sm") export_ass_btn = gr.DownloadButton("💾 ייצא ASS", variant="secondary", size="sm") # ── צד ימין: פריוויו בסגנון טלפון ───────────────────────── with gr.Column(scale=2): gr.HTML('''
תצוגה מקדימה LIVE PREVIEW
''') preview_accordion = gr.Accordion("", open=True, visible=True) with preview_accordion: gr.HTML('''
HebSub · 9:16
''') preview_img = gr.Image( label="", show_download_button=False, height=400, show_label=False, container=False, elem_classes="preview-img-wrap", ) gr.HTML('''
''') preview_btn = gr.Button("🔄 עדכן תצוגה מקדימה", variant="secondary", size="sm") with gr.Group(visible=False) as step4_group: gr.HTML('
3עיצוב וצריבה
') # ── סגנונות קסם ─────────────────────────────────────────────────── gr.HTML('''
✨ סגנון קסם — לחץ ובחר
🖱️ עמוד על סגנון עם העכבר לפני הלחיצה — תראה הסבר קצר
''') with gr.Row(): style_hormozi = gr.Button("🔥 הורמוזי", variant="secondary", size="sm") style_vlogger = gr.Button("🎬 ולוגר", variant="secondary", size="sm") style_dramatic = gr.Button("💜 דרמטי", variant="secondary", size="sm") style_minimal = gr.Button("🤍 מינימליסטי", variant="secondary", size="sm") style_fire = gr.Button("🔴 אש", variant="secondary", size="sm") style_label = gr.Markdown(value="", visible=False) gr.HTML('
') with gr.Accordion("🎨 כיוונון עדין (אופציונלי)", open=False): with gr.Row(): font_sel_v = gr.Dropdown(FONTS, value="Heebo", label="פונט") gr.Markdown('מומלץ: Heebo, Rubik, Assistant לעברית מושלמת.') with gr.Row(): hook_color_v = gr.ColorPicker(value="#FFFF00", label="🎨 צבע Hook") body_color_v = gr.ColorPicker(value="#FFFFFF", label="🎨 צבע Body") with gr.Row(): hook_size_v = gr.Slider(50, 120, value=85, step=1, label="📏 גודל Hook") body_size_v = gr.Slider(30, 100, value=62, step=1, label="📏 גודל Body") outline_v = gr.Slider(0, 6, value=3, step=1, label="🖊️ עובי מסגרת") # hook_lines ו-position עברו ל-accordion מיקום למטה with gr.Accordion("🎵 מצב Pop-up (TikTok)", open=False, elem_classes="karaoke-box"): karaoke_mode_v = gr.Checkbox(value=False, label="🎤 הפעל Pop-up — כל מילה קופצת בנפרד") karaoke_color_v = gr.ColorPicker(value="#FFFF00", label="🌟 צבע הדגשה") gr.Markdown('💡 לתוצאה מדויקת יותר — תמלל עם מצב זה מופעל מראש.') with gr.Accordion("📐 מיקום", open=False): position_v = gr.Radio(["תחתית", "מרכז", "עליון"], value="תחתית", label="מיקום כתוביות") hook_lines_v = gr.Slider(1, 5, value=2, step=1, label="מספר שורות Hook") burn_btn = gr.Button("🔥 צרוב כתוביות לסרטון", variant="secondary", size="lg") video_out = gr.Video(label="🎬 הסרטון המוכן", show_download_button=True, height=400, elem_classes="video-out-wrap") download_btn = gr.DownloadButton( label="⬇️ הורד סרטון מוכן", variant="primary", size="lg", visible=False, elem_id="download-btn" ) reset_btn = gr.Button( "🔄 סרטון חדש — נקה ושמור הגדרות", variant="secondary", size="sm", visible=False, elem_id="reset-btn" ) gr.HTML(TIPS_HTML) # ─── Events ────────────────────────────────────────────────────────── def on_karaoke_toggle(is_on): if is_on: return gr.update(value='⚠️ מצב Pop-up פעיל — חובה לתמלל מחדש כדי שיעבוד! לחץ שוב על כפתור התמלול.') return gr.update(value='💡 טיפ: לחץ על כל תא כדי לערוך את הטקסט ישירות.') def on_upload_with_btn(video_path, model_size, trim_start, trim_end): trim_s, trim_e, warning, btn = on_video_upload(video_path) time_est = update_time_estimate(video_path, model_size, trim_start, trim_end) return trim_s, trim_e, btn, time_est, warning video_in.change( fn=on_upload_with_btn, inputs=[video_in, model_sel, trim_start, trim_end], outputs=[trim_start, trim_end, transcribe_btn, time_estimate_box, upload_warning] ) # עדכון זמן משוער גם כשמשנים מודל או חיתוך model_sel.change( fn=update_time_estimate, inputs=[video_in, model_sel, trim_start, trim_end], outputs=time_estimate_box ) trim_start.release( fn=update_time_estimate, inputs=[video_in, model_sel, trim_start, trim_end], outputs=time_estimate_box ) trim_end.release( fn=update_time_estimate, inputs=[video_in, model_sel, trim_start, trim_end], outputs=time_estimate_box ) def run_waveform(video_path, segments): """רץ אחרי התמלול — לא חוסם את התוצאה למשתמש.""" if not segments or not video_path: return gr.update(visible=False) workdir = Path(tempfile.mkdtemp()) try: audio_path = workdir / "audio_wf.wav" r = subprocess.run( ["ffmpeg", "-y", "-i", str(video_path), "-ar", "4000", "-ac", "1", "-c:a", "pcm_s16le", str(audio_path)], capture_output=True, timeout=30 ) if r.returncode != 0 or not audio_path.exists(): return gr.update(visible=False) total_dur = segments[-1]["end"] if segments else 60 wf = generate_waveform(str(audio_path), segments, total_dur) return gr.update(value=wf, visible=wf is not None) except Exception: return gr.update(visible=False) finally: shutil.rmtree(workdir, ignore_errors=True) # שלב א: השבת מיד בלחיצה — מונע לחיצה כפולה transcribe_btn.click( fn=lambda: gr.update(value="⏳ מתמלל... אנא המתן", interactive=False), inputs=[], outputs=transcribe_btn ).then( # שלב ב: מריץ את התמלול עצמו fn=do_transcribe, inputs=[video_in, model_sel, trim_start, trim_end, karaoke_mode_v], outputs=[status_out, subtitle_table, segments_state, burn_btn, preview_accordion, export_row, waveform_img] ).then( fn=lambda: (gr.update(visible=True), gr.update(visible=True)), outputs=[step3_group, step4_group] ).then( # שלב ג: החזר את הכפתור לפעולה fn=lambda: gr.update(value="🎙️ שלב 2 — תמלול אוטומטי", interactive=True), inputs=[], outputs=transcribe_btn ).then( # שלב ד: waveform — רץ אחרון, לא מאט את התמלול fn=run_waveform, inputs=[video_in, segments_state], outputs=waveform_img ).then( # שלב ה: ניתוח וויראליות — רץ אחרי הwaveform fn=lambda segs: ( gr.update(value=analyze_virality(segs, segs[-1]["end"] if segs else 0), visible=True) if segs else gr.update(visible=False) ), inputs=[segments_state], outputs=virality_box ) export_srt_btn.click(fn=export_srt, inputs=subtitle_table, outputs=export_srt_btn) export_ass_btn.click(fn=export_ass, inputs=[subtitle_table, font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v], outputs=export_ass_btn) karaoke_mode_v.change(fn=on_karaoke_toggle, inputs=karaoke_mode_v, outputs=karaoke_warning) # ── סגנונות קסם ─────────────────────────────────────────────────────── def apply_style(style_name): s = MAGIC_STYLES[style_name] return ( gr.update(value=s["font"]), gr.update(value=s["hook_color"]), gr.update(value=s["body_color"]), gr.update(value=s["hook_size"]), gr.update(value=s["body_size"]), gr.update(value=s["outline"]), gr.update(value=s["position"]), gr.update(value=f'✅ סגנון {style_name} הוחל — {s["desc"]}', visible=True), ) STYLE_OUTPUTS = [font_sel_v, hook_color_v, body_color_v, hook_size_v, body_size_v, outline_v, position_v, style_label] for btn, name in [ (style_hormozi, "🔥 הורמוזי"), (style_vlogger, "🎬 ולוגר"), (style_dramatic, "💜 דרמטי"), (style_minimal, "🤍 מינימליסטי"), (style_fire, "🔴 אש"), ]: btn.click(fn=lambda n=name: apply_style(n), inputs=[], outputs=STYLE_OUTPUTS) # ── פיצול שורה ─────────────────────────────────────────────────────── split_btn.click( fn=split_row, inputs=[subtitle_table, row_num_input], outputs=[subtitle_table, edit_status] ).then( fn=lambda msg: gr.update(value=msg, visible=True), inputs=edit_status, outputs=edit_status ) # ── איחוד שורות ────────────────────────────────────────────────────── merge_btn.click( fn=merge_rows, inputs=[subtitle_table, row_num_input], outputs=[subtitle_table, edit_status] ).then( fn=lambda msg: gr.update(value=msg, visible=True), inputs=edit_status, outputs=edit_status ) # ── פריוויו ידני (כפתור) ───────────────────────────────────────────── preview_btn.click( fn=lambda: gr.update(value="⏳ שולף frame מהסרטון..."), inputs=[], outputs=preview_btn ).then( fn=generate_preview, inputs=[font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v, subtitle_table, video_in, karaoke_mode_v], outputs=preview_img ).then( fn=lambda: gr.update(value="🔄 עדכן תצוגה מקדימה"), inputs=[], outputs=preview_btn ) # ── פריוויו אוטומטי: טבלה — הוסר כדי לא להאט את התמלול # (subtitle_table.change מופעל גם כשהתמלול ממלא את הטבלה — מחנק CPU) # המשתמש יכול לעדכן ידנית עם כפתור "עדכן תצוגה מקדימה" # ── פריוויו אוטומטי: עיצוב ────────────────────────────────────────── # dropdown + colorpicker — .change בסדר (אירוע חד-פעמי) for design_input in [font_sel_v, hook_color_v, body_color_v, position_v]: design_input.change( fn=generate_preview, inputs=[font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v, subtitle_table, video_in, karaoke_mode_v], outputs=preview_img ) # sliders — רק כשמשחררים, לא בכל הזזה (מונע עומס CPU!) for slider_input in [hook_size_v, body_size_v, outline_v]: slider_input.release( fn=generate_preview, inputs=[font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v, subtitle_table, video_in, karaoke_mode_v], outputs=preview_img ) # ─── כפתור צריבה: נחסם מיד בלחיצה, חוזר אחרי סיום ───────────────── # שלב א: השבת מיד בלחיצה (מונע לחיצה כפולה בטעות) burn_btn.click( fn=lambda: gr.update(value="⏳ צורב... אנא המתן", interactive=False), inputs=[], outputs=burn_btn ).then( # שלב ב: מריץ את הצריבה עצמה, ומחזיר גם את מצב הכפתור fn=do_burn, inputs=[video_in, subtitle_table, font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, karaoke_color_v, outline_v, position_v, hook_lines_v, karaoke_mode_v, segments_state], outputs=[video_out, status_out, download_btn, burn_btn, reset_btn] ) # ─── כפתור סרטון חדש: מנקה וידאו + כתוביות, שומר עיצוב ──────────── def do_reset(): """מנקה את הסרטון, הכתוביות והסטטוס — אבל שומר גופן, צבעים וגדלים.""" return ( None, # video_in empty_df(), # subtitle_table [], # segments_state "", # status_out None, # video_out gr.update(visible=False), # download_btn gr.update(visible=False), # reset_btn gr.update(visible=False), # step3_group gr.update(visible=False), # step4_group gr.update(value="", visible=False), # time_estimate_box gr.update(interactive=False), # transcribe_btn gr.update(value="🔥 צרוב כתוביות לסרטון", interactive=False), # burn_btn gr.update(value=None, visible=False), # waveform_img gr.update(value="", visible=False), # upload_warning gr.update(value="", visible=False), # style_label gr.update(value="", visible=False), # virality_box ) reset_btn.click( fn=do_reset, inputs=[], outputs=[ video_in, subtitle_table, segments_state, status_out, video_out, download_btn, reset_btn, step3_group, step4_group, time_estimate_box, transcribe_btn, burn_btn, waveform_img, upload_warning, style_label, virality_box, ] ) if __name__ == "__main__": demo.launch(share=False)