#!/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 = """