| |
| """ |
| 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 |
|
|
| |
| from faster_whisper import WhisperModel |
| import time as _time |
|
|
| OUTPUT_DIR = Path("/tmp/hebsub_output") |
| OUTPUT_DIR.mkdir(exist_ok=True) |
|
|
| |
| MAX_TRANSCRIBE_SECONDS = 300 |
|
|
|
|
|
|
| def cleanup_old_outputs(max_age_seconds: int = 3600): |
| """ |
| מחק קבצים ותיקיות ישנים — מונע מילוי דיסק ב-HuggingFace Spaces. |
| רץ בכל צריבה. מנקה: |
| 1. קבצי MP4 בתיקיית הפלט (סרטונים ישנים) |
| 2. תיקיות עבודה זמניות שנשארו (אחרי תמלולים כושלים) |
| """ |
| now = _time.time() |
| |
| for f in OUTPUT_DIR.glob("*.mp4"): |
| try: |
| if now - f.stat().st_mtime > max_age_seconds: |
| f.unlink() |
| except Exception: |
| pass |
| |
| |
| 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: |
| |
| 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}) |
| |
| 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) |
|
|
|
|
| |
| import threading as _threading |
| _whisper_model = None |
| _whisper_model_size = None |
| _model_lock = _threading.Lock() |
|
|
| |
| |
| _transcribe_lock = _threading.Lock() |
|
|
| |
| |
| _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: |
| |
| _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]: |
| """ |
| מחשב זמן תמלול משוער בשניות. |
| מחזיר: (מינימום, אופטימי, מקסימום) בשניות. |
| """ |
| |
| |
| |
| speed = {"base": 2.0, "small": 1.5, "medium": 0.7, "large-v3": 0.3}.get(model_size, 0.7) |
| overhead = 6 |
| 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 |
| 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_bytes = audio_path.stat().st_size - 44 |
| audio_dur = max(1.0, wav_bytes / (16000 * 2)) |
| except Exception: |
| audio_dur = dur if dur > 0 else 60.0 |
|
|
| |
| 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() |
|
|
| |
| segments_gen, info = model.transcribe( |
| str(audio_path), |
| language="he", |
| word_timestamps=bool(karaoke_mode), |
| 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: |
| |
| 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}") |
|
|
| |
| 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: |
| |
| 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 = 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" |
| |
| |
| safe_ass = workdir / "subs_burn.ass" |
| shutil.copy2(ass_path, safe_ass) |
|
|
| |
| |
| |
| 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: |
| |
| 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 = """ |
| <link rel="manifest" href="data:application/json;charset=utf-8,%7B%22name%22%3A%22HebSub%20%E2%9A%A1%22%2C%22short_name%22%3A%22HebSub%22%2C%22description%22%3A%22%D7%9B%D7%AA%D7%95%D7%91%D7%99%D7%95%D7%AA%20%D7%A2%D7%91%D7%A8%D7%99%D7%AA%20%D7%90%D7%95%D7%98%D7%95%D7%9E%D7%98%D7%99%D7%95%D7%AA%22%2C%22start_url%22%3A%22.%22%2C%22display%22%3A%22standalone%22%2C%22background_color%22%3A%22%2313131f%22%2C%22theme_color%22%3A%22%23f0c040%22%2C%22orientation%22%3A%22portrait%22%2C%22icons%22%3A%5B%7B%22src%22%3A%22https%3A%2F%2Fhuggingface.co%2Ffront%2Fassets%2Fhuggingface_logo.svg%22%2C%22sizes%22%3A%22192x192%22%2C%22type%22%3A%22image%2Fsvg%2Bxml%22%7D%5D%7D"> |
| <meta name="mobile-web-app-capable" content="yes"> |
| <meta name="apple-mobile-web-app-capable" content="yes"> |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| <meta name="apple-mobile-web-app-title" content="HebSub ⚡"> |
| <meta name="theme-color" content="#f0c040"> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> |
| """ |
|
|
| HEADER_HTML = """ |
| <div class="hero"> |
| <div class="hero-title">HebSub ⚡</div> |
| <div class="hero-sub">תמלול עברית אוטומטי • כתוביות TikTok • צריבה ישירה לסרטון</div> |
| <div style="margin-top:10px; color:#aaaacc; font-size:15px; font-weight:700; letter-spacing:1px;"> |
| 📹 העלה → 🎙️ תמלל → 🔥 צרוב — הכל ב-2 דקות |
| </div> |
| <div style="display:flex; justify-content:center; gap:12px; flex-wrap:wrap; margin-top:16px;"> |
| <div style="background:rgba(240,192,64,0.1); border:1px solid rgba(240,192,64,0.3); border-radius:20px; padding:6px 16px; font-size:13px; color:#f0c040;"> |
| 🔥 ניתוח וויראליות אוטומטי |
| </div> |
| <div style="background:rgba(100,200,100,0.1); border:1px solid rgba(100,200,100,0.3); border-radius:20px; padding:6px 16px; font-size:13px; color:#88dd88;"> |
| ✨ 5 סגנונות קסם מוכנים |
| </div> |
| <div style="background:rgba(100,150,255,0.1); border:1px solid rgba(100,150,255,0.3); border-radius:20px; padding:6px 16px; font-size:13px; color:#8899ff;"> |
| ⚡ תמלול מהיר עם faster-whisper |
| </div> |
| <div style="background:rgba(200,100,255,0.1); border:1px solid rgba(200,100,255,0.3); border-radius:20px; padding:6px 16px; font-size:13px; color:#cc88ff;"> |
| 🎬 פריוויו אמיתי לפני הצריבה |
| </div> |
| </div> |
| </div> |
| """ |
|
|
| TIPS_HTML = """ |
| <div class="tips-bar"> |
| <b>Heebo</b> לטקסט נקי • <b>Rubik</b> לסגנון מודרני • <b>Pop-up</b> לסגנון TikTok • מודל <b>medium</b> — מהיר ומדויק |
| </div> |
| """ |
|
|
|
|
|
|
|
|
| def split_row(df, row_num_str: str): |
| """מפצל שורה אחת לשתיים — חולק את הטקסט ואת הזמן שווה בשווה.""" |
| try: |
| row_num = int(str(row_num_str).strip()) - 1 |
| 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 |
| 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 |
|
|
| |
| 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 |
|
|
| |
| |
| 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) |
|
|
| |
| n = len(samples) |
| step = max(1, n // W) |
| |
| trim = (n // step) * step |
| blocks = samples[:trim].reshape(-1, step) |
| peaks = np.abs(blocks).max(axis=1) |
| |
| 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 |
|
|
| bar_heights = np.maximum(2, (peaks * (WAVE_H / 2) * 0.92).astype(int)) |
|
|
| |
| for xi, bar_h in enumerate(bar_heights): |
| |
| 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] |
|
|
| |
| 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 |
|
|
|
|
|
|
| |
| 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 |
|
|
| |
| img = None |
| if video_path is not None: |
| try: |
| dur = get_video_duration(video_path) |
| if dur > 0: |
| |
| 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 |
|
|
| |
| 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): |
| |
| 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_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) |
|
|
| |
| 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'<div style="background:#2a0a0a; border:1px solid #aa2222; border-radius:10px; ' |
| f'padding:12px 16px; color:#ff8888; font-size:14px; font-weight:700; margin:8px 0;">' |
| f'❌ הקובץ גדול מדי — {file_mb:.0f}MB (מגבלה: {MAX_FILE_MB}MB)<br>' |
| f'<span style="font-weight:400; color:#cc6666;">💡 דחוס את הוידאו ב-WhatsApp או ב-CapCut ונסה שוב.</span>' |
| f'</div>' |
| ) |
| 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'<div style="background:#2a1a0a; border:1px solid #aa6622; border-radius:10px; ' |
| f'padding:12px 16px; color:#ffaa66; font-size:14px; font-weight:700; margin:8px 0;">' |
| f'⚠️ הסרטון ארוך מדי — {dur:.0f} שניות (מגבלה: {MAX_DURATION_SECS} שניות)<br>' |
| f'<span style="font-weight:400; color:#cc8844;">💡 השתמש בחיתוך למטה כדי לבחור קטע של עד {MAX_DURATION_SECS} שניות.</span>' |
| f'</div>' |
| ) |
| 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'<div class="time-estimate">⏱️ הוידאו: {vid_str} • זמן תמלול משוער: {time_str} (מודל {model_size})</div>' |
| 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": "כתום-אדום — אנרגטי ונועז" |
| }, |
| } |
|
|
|
|
| |
| 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 "" |
|
|
| |
| 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("השורה הראשונה חלשה — נסה לפתוח עם שאלה או מילה מפתיעה") |
|
|
| |
| 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("סרטון ארוך מדי — שקול לחתוך לקטע הכי חזק") |
|
|
| |
| 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("חסרה קריאה לפעולה — הוסף 'שתף אם עזר' / 'עקוב לעוד'") |
|
|
| |
| 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("כתוביות ארוכות מדי — קשה לקרוא בגלילה") |
|
|
| |
| 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'<div style="color:#88ffaa; font-size:12px; margin:2px 0;">✅ {t}</div>' for t in tips_good) |
| bad_html = "".join(f'<div style="color:#ffaa66; font-size:12px; margin:2px 0;">💡 {t}</div>' for t in tips_bad) |
|
|
| return f""" |
| <div style="background:linear-gradient(135deg,#0d1a0d,#0a0a18); border:1px solid #2a4a2a; |
| border-radius:12px; padding:14px 18px; margin:10px 0;"> |
| <div style="display:flex; align-items:center; gap:12px; margin-bottom:10px;"> |
| <div style="font-size:28px; font-weight:900; color:{score_color};">{score}</div> |
| <div> |
| <div style="color:{score_color}; font-weight:900; font-size:14px;">{score_label}</div> |
| <div style="color:#666688; font-size:11px;">ציון וויראליות</div> |
| </div> |
| <div style="margin-right:auto; background:rgba(255,255,255,0.05); border-radius:8px; |
| padding:4px 10px; font-size:11px; color:#888;"> |
| {total_dur:.0f}s • {len(segments)} קטעים • {total_words} מילים |
| </div> |
| </div> |
| {good_html} |
| {bad_html} |
| </div>""" |
|
|
| |
| 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('<div class="step-header"><span class="step-num">1</span><span class="step-title">העלה סרטון ותמלל</span></div>') |
| 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('<small style="color:#888">מגבלה: 90 שניות • עד 500MB.</small>') |
|
|
| with gr.Accordion("✂️ חיתוך וידאו (אופציונלי)", open=False): |
| gr.Markdown('<small style="color:#888">לסרטונים ארוכים — חתוך לקטע הרצוי לפני תמלול.</small>') |
| 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('<small style="color:#666">⚡ מואץ עם faster-whisper — מהיר פי 3-4 מגרסה קודמת</small>') |
| 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('<div class="step-header"><span class="step-num">2</span><span class="step-title">ערוך כתוביות (אופציונלי)</span></div>') |
|
|
| with gr.Row(): |
| |
| with gr.Column(scale=3): |
| gr.HTML(''' |
| <div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;"> |
| <div style="width:3px; height:20px; background:#f0c040; border-radius:2px;"></div> |
| <span style="color:#e8e8f0; font-weight:900; font-size:15px;">Timeline</span> |
| <span style="color:#444466; font-size:12px; margin-right:auto;">לחץ על כל שורה לעריכה</span> |
| </div>''') |
| 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='<small style="color:#444460">💡 לחץ על תא כדי לערוך • שינויים ישמרו אוטומטית</small>') |
|
|
| |
| 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(''' |
| <div style="display:flex; align-items:center; gap:10px; margin-bottom:10px;"> |
| <div style="width:3px; height:20px; background:#8866ff; border-radius:2px;"></div> |
| <span style="color:#e8e8f0; font-weight:900; font-size:15px;">תצוגה מקדימה</span> |
| <span class="preview-badge">LIVE PREVIEW</span> |
| </div>''') |
| preview_accordion = gr.Accordion("", open=True, visible=True) |
| with preview_accordion: |
| gr.HTML('''<div class="preview-wrap"> |
| <div class="preview-phone-bar"> |
| <span class="preview-phone-dot"></span> |
| <span>HebSub · 9:16</span> |
| </div>''') |
| preview_img = gr.Image( |
| label="", |
| show_download_button=False, |
| height=400, |
| show_label=False, |
| container=False, |
| elem_classes="preview-img-wrap", |
| ) |
| gr.HTML('''<div class="preview-footer"> |
| <span>1080 × 1920</span> |
| <span>TikTok / Reels</span> |
| </div></div>''') |
| preview_btn = gr.Button("🔄 עדכן תצוגה מקדימה", variant="secondary", size="sm") |
|
|
| with gr.Group(visible=False) as step4_group: |
| gr.HTML('<div class="step-header"><span class="step-num">3</span><span class="step-title">עיצוב וצריבה</span></div>') |
|
|
| |
| gr.HTML('''<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;"> |
| <div style="width:3px; height:20px; background:#f0c040; border-radius:2px;"></div> |
| <span style="color:#e8e8f0; font-weight:900; font-size:14px;">✨ סגנון קסם — לחץ ובחר</span> |
| </div> |
| <div style="color:#666688; font-size:11px; margin-bottom:10px; margin-right:14px;"> |
| 🖱️ עמוד על סגנון עם העכבר לפני הלחיצה — תראה הסבר קצר |
| </div> |
| <div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px;"> |
| <button title="צהוב + לבן, Impact גדול — TikTok קלאסי" |
| onclick="return false;" |
| style="background:#1a1a2e; border:1px solid #3a3a5e; color:#c8c8e0; border-radius:8px; |
| padding:8px 14px; font-size:13px; font-weight:700; cursor:default;"> |
| 🔥 הורמוזי |
| </button> |
| <button title="לבן נקי, Heebo — סגנון YouTube / vlog" |
| onclick="return false;" |
| style="background:#1a1a2e; border:1px solid #3a3a5e; color:#c8c8e0; border-radius:8px; |
| padding:8px 14px; font-size:13px; font-weight:700; cursor:default;"> |
| 🎬 ולוגר |
| </button> |
| <button title="סגול-ורוד, מרכז — תוכן רגשי ודרמטי" |
| onclick="return false;" |
| style="background:#1a1a2e; border:1px solid #3a3a5e; color:#c8c8e0; border-radius:8px; |
| padding:8px 14px; font-size:13px; font-weight:700; cursor:default;"> |
| 💜 דרמטי |
| </button> |
| <button title="קטן ועדין, Assistant — תוכן רציני" |
| onclick="return false;" |
| style="background:#1a1a2e; border:1px solid #3a3a5e; color:#c8c8e0; border-radius:8px; |
| padding:8px 14px; font-size:13px; font-weight:700; cursor:default;"> |
| 🤍 מינימליסטי |
| </button> |
| <button title="כתום-אדום, Impact ענק — אנרגטי ונועז" |
| onclick="return false;" |
| style="background:#1a1a2e; border:1px solid #3a3a5e; color:#c8c8e0; border-radius:8px; |
| padding:8px 14px; font-size:13px; font-weight:700; cursor:default;"> |
| 🔴 אש |
| </button> |
| </div>''') |
| 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('<div style="border-top:1px solid #1a1a2e; margin:12px 0 4px;"></div>') |
|
|
| with gr.Accordion("🎨 כיוונון עדין (אופציונלי)", open=False): |
| with gr.Row(): |
| font_sel_v = gr.Dropdown(FONTS, value="Heebo", label="פונט") |
| gr.Markdown('<small style="color:#666688">מומלץ: Heebo, Rubik, Assistant לעברית מושלמת.</small>') |
| 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="🖊️ עובי מסגרת") |
| |
|
|
| 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('<small style="color:#8866aa">💡 לתוצאה מדויקת יותר — תמלל עם מצב זה מופעל מראש.</small>') |
|
|
| 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) |
|
|
| |
| def on_karaoke_toggle(is_on): |
| if is_on: |
| return gr.update(value='<small style="color:#ffaa44">⚠️ מצב Pop-up פעיל — <b>חובה לתמלל מחדש</b> כדי שיעבוד! לחץ שוב על כפתור התמלול.</small>') |
| return gr.update(value='<small style="color:#444460">💡 טיפ: לחץ על כל תא כדי לערוך את הטקסט ישירות.</small>') |
|
|
| 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( |
| |
| fn=run_waveform, |
| inputs=[video_in, segments_state], |
| outputs=waveform_img |
| ).then( |
| |
| 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'<small style="color:#aaffaa">✅ סגנון <b>{style_name}</b> הוחל — {s["desc"]}</small>', 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 |
| ) |
|
|
| |
| |
| |
|
|
| |
| |
| 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 |
| ) |
| |
| 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, |
| empty_df(), |
| [], |
| "", |
| None, |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(value="", visible=False), |
| gr.update(interactive=False), |
| gr.update(value="🔥 צרוב כתוביות לסרטון", interactive=False), |
| gr.update(value=None, visible=False), |
| gr.update(value="", visible=False), |
| gr.update(value="", visible=False), |
| gr.update(value="", visible=False), |
| ) |
|
|
| 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) |