HebSub / app.py
RobG25's picture
Update app.py
fbc2ed5 verified
Raw
History Blame Contribute Delete
105 kB
#!/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 = """
<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;">
📹 העלה &nbsp;→&nbsp; 🎙️ תמלל &nbsp;→&nbsp; 🔥 צרוב &nbsp;—&nbsp; הכל ב-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> לטקסט נקי &nbsp;•&nbsp; <b>Rubik</b> לסגנון מודרני &nbsp;•&nbsp; <b>Pop-up</b> לסגנון TikTok &nbsp;•&nbsp; מודל <b>medium</b> — מהיר ומדויק
</div>
"""
def split_row(df, row_num_str: str):
"""מפצל שורה אחת לשתיים — חולק את הטקסט ואת הזמן שווה בשווה."""
try:
row_num = int(str(row_num_str).strip()) - 1 # 1-based → 0-based
chunks = df_to_chunks(df)
if not chunks or row_num < 0 or row_num >= len(chunks):
return df, f"❌ שורה {row_num+1} לא קיימת. יש {len(chunks)} שורות."
chunk = chunks[row_num]
words = chunk["text"].split()
if len(words) < 2:
return df, "❌ השורה קצרה מדי לפיצול (מילה אחת בלבד)."
mid = len(words) // 2
mid_time = chunk["start"] + (chunk["end"] - chunk["start"]) / 2
first = {"start": chunk["start"], "end": mid_time, "text": " ".join(words[:mid])}
second = {"start": mid_time, "end": chunk["end"], "text": " ".join(words[mid:])}
new_chunks = chunks[:row_num] + [first, second] + chunks[row_num+1:]
return chunks_to_df(new_chunks), f"✅ שורה {row_num+1} פוצלה לשתיים."
except Exception as e:
return df, f"❌ שגיאה: {e}"
def merge_rows(df, row_num_str: str):
"""מאחד שורה עם השורה שאחריה — מחבר טקסט ולוקח את זמן ההתחלה/סיום."""
try:
row_num = int(str(row_num_str).strip()) - 1 # 1-based → 0-based
chunks = df_to_chunks(df)
if not chunks or row_num < 0 or row_num >= len(chunks) - 1:
return df, f"❌ שורה {row_num+1} לא ניתן לאחד (לא קיימת או אחרונה)."
a = chunks[row_num]
b = chunks[row_num + 1]
merged = {"start": a["start"], "end": b["end"],
"text": a["text"] + " " + b["text"]}
new_chunks = chunks[:row_num] + [merged] + chunks[row_num+2:]
return chunks_to_df(new_chunks), f"✅ שורות {row_num+1} ו-{row_num+2} אוחדו."
except Exception as e:
return df, f"❌ שגיאה: {e}"
def generate_waveform(audio_path: str, segments: list, total_dur: float) -> object:
"""
יוצר תמונת waveform במהירות גבוהה עם numpy.
במקום לצייר pixel-by-pixel (איטי), בונים מערך numpy שלם ומעבירים ל-Pillow.
מהיר פי ~10 מהגרסה הקודמת.
"""
try:
from PIL import Image, ImageDraw, ImageFont
import wave as _wave
import numpy as np
# ── חילוץ אודיו בדגימה נמוכה — 4000Hz מספיק לוויזואליזציה ───────
tmp_wav = Path(tempfile.mktemp(suffix=".wav"))
r = subprocess.run(
["ffmpeg", "-y", "-i", str(audio_path),
"-ar", "4000", "-ac", "1", "-c:a", "pcm_s16le", str(tmp_wav)],
capture_output=True
)
if r.returncode != 0 or not tmp_wav.exists():
return None
with _wave.open(str(tmp_wav), "rb") as wf:
raw = wf.readframes(wf.getnframes())
tmp_wav.unlink(missing_ok=True)
samples = np.frombuffer(raw, dtype=np.int16).astype(np.float32)
if len(samples) == 0:
return None
# ── ממדים ────────────────────────────────────────────────────────
W, H = 900, 140
WAVE_H = 80
AXIS_H = 30
PAD = 10
cy = PAD + WAVE_H // 2
# ── בניית מערך הפיקסלים עם numpy (במקום לולאות Python) ──────────
# canvas שחור-כחול
canvas = np.zeros((H, W, 3), dtype=np.uint8)
canvas[:, :] = [9, 9, 18]
# רקע מדורג — וקטורי לחלוטין
ys = np.arange(H, dtype=np.float32)
shade = (8 + (ys / H) * 6).astype(np.uint8)
canvas[:, :, 0] = shade[:, None]
canvas[:, :, 1] = shade[:, None]
canvas[:, :, 2] = np.clip(shade[:, None].astype(np.int16) + 12, 0, 255).astype(np.uint8)
# ── חישוב peak לכל עמודה בוקטור אחד ─────────────────────────────
n = len(samples)
step = max(1, n // W)
# חיתוך לאורך שמתחלק ב-step
trim = (n // step) * step
blocks = samples[:trim].reshape(-1, step) # (blocks, step)
peaks = np.abs(blocks).max(axis=1) # peak לכל block
# pad / crop ל-W עמודות
if len(peaks) < W:
peaks = np.pad(peaks, (0, W - len(peaks)))
else:
peaks = peaks[:W]
max_amp = peaks.max() or 1.0
peaks = peaks / max_amp # normalize 0..1
bar_heights = np.maximum(2, (peaks * (WAVE_H / 2) * 0.92).astype(int))
# ── ציור הגל — לולאה רק על W עמודות, פנימה numpy slice ──────────
for xi, bar_h in enumerate(bar_heights):
# גרדיאנט: t=0 בשיא (צבע כחול כהה), t=1 בבסיס (כחול-סגול)
dy_arr = np.arange(bar_h, dtype=np.float32)
t = dy_arr / max(bar_h, 1)
r_arr = (60 + t * 80).astype(np.uint8)
g_arr = (40 + t * 30).astype(np.uint8)
b_arr = (180 + t * 60).astype(np.uint8)
# חצי עליון
y_ups = cy - dy_arr.astype(int)
mask = (y_ups >= PAD) & (y_ups < PAD + WAVE_H)
canvas[y_ups[mask], xi] = np.stack([r_arr[mask], g_arr[mask], b_arr[mask]], axis=1)
# חצי תחתון
y_dns = cy + dy_arr.astype(int)
mask = (y_dns >= PAD) & (y_dns < PAD + WAVE_H)
canvas[y_dns[mask], xi] = np.stack([r_arr[mask], g_arr[mask], b_arr[mask]], axis=1)
# ── קו אמצע ──────────────────────────────────────────────────────
canvas[cy, :] = [40, 40, 80]
# ── הדגשות כתוביות — numpy slice, אפס לולאות פיקסל ─────────────
for seg in segments:
x0 = int((seg["start"] / max(total_dur, 1)) * W)
x1 = int((seg["end"] / max(total_dur, 1)) * W)
x1 = max(x1, x0 + 3)
x1 = min(x1, W)
if x0 >= x1:
continue
region = canvas[PAD:PAD + WAVE_H, x0:x1].astype(np.float32)
region[:, :, 0] = np.clip(region[:, :, 0] * 0.55 + 200, 0, 255)
region[:, :, 1] = np.clip(region[:, :, 1] * 0.55 + 160, 0, 255)
region[:, :, 2] = np.clip(region[:, :, 2] * 0.20, 0, 255)
canvas[PAD:PAD + WAVE_H, x0:x1] = region.astype(np.uint8)
# קו אנכי צהוב בתחילת כל כתובית
if x0 < W:
canvas[PAD:PAD + WAVE_H, x0] = [255, 210, 0]
img = Image.fromarray(canvas, "RGB")
draw = ImageDraw.Draw(img)
# ── ציר זמן ──────────────────────────────────────────────────────
axis_y = PAD + WAVE_H + 2
draw.rectangle([(0, axis_y), (W, axis_y + AXIS_H)], fill=(6, 6, 14))
draw.line([(0, axis_y), (W, axis_y)], fill=(30, 30, 60), width=1)
try:
font_sm = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf", 10)
except Exception:
font_sm = ImageFont.load_default()
tick_interval = 5 if total_dur <= 60 else 10
t_mark = 0
while t_mark <= total_dur:
xm = int((t_mark / max(total_dur, 1)) * W)
draw.line([(xm, axis_y), (xm, axis_y + 6)], fill=(80, 80, 120), width=1)
m, s = divmod(int(t_mark), 60)
draw.text((xm + 2, axis_y + 7), f"{m}:{s:02d}", font=font_sm, fill=(100, 100, 160))
t_mark += tick_interval
# ── תווית WAVEFORM ────────────────────────────────────────────────
label_y = axis_y + AXIS_H + 2
draw.rectangle([(0, label_y), (W, H)], fill=(6, 6, 14))
try:
font_label = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 9)
except Exception:
font_label = ImageFont.load_default()
draw.text((6, label_y + 4), "▶ WAVEFORM", font=font_label, fill=(50, 50, 100))
draw.text((W - 80, label_y + 4), f"{total_dur:.1f}s total", font=font_label, fill=(50, 50, 100))
# הדגשה צהובה בלגנד
draw.rectangle([(W//2 - 60, label_y + 5), (W//2 - 50, label_y + 13)],
fill=(60, 48, 10))
draw.text((W//2 - 46, label_y + 4), "= קטע כתובית", font=font_label, fill=(80, 70, 30))
return img
except Exception:
return None
def generate_preview(font_name, hook_size, body_size, hook_hex, body_hex,
outline_size, position, hook_lines, subtitle_table, video_path=None, karaoke_mode=False):
"""
תצוגה מקדימה אמיתית:
- שולף frame מאמצע הסרטון עם FFmpeg
- מצייר את הכתוביות האמצעיות עליו
- אם אין סרטון — חוזר לרקע שחור גנרי
"""
try:
from PIL import Image, ImageDraw, ImageFont
import io
chunks = df_to_chunks(subtitle_table) if subtitle_table is not None else []
# ── בחירת כתוביות לתצוגה: האמצעיות בסרטון ──────────────────────
if len(chunks) == 0:
mid_chunks = []
elif len(chunks) == 1:
mid_chunks = [chunks[0]]
else:
mid_i = len(chunks) // 2
mid_chunks = chunks[mid_i: mid_i + 2]
hook_text = mid_chunks[0]["text"] if len(mid_chunks) > 0 else "שלום עולם"
body_text = mid_chunks[1]["text"] if len(mid_chunks) > 1 else ""
W, H = 540, 960
# ── שליפת frame אמיתי מהסרטון ───────────────────────────────────
img = None
if video_path is not None:
try:
dur = get_video_duration(video_path)
if dur > 0:
# frame מהכתובית האמצעית עצמה (לא אמצע הסרטון בזמן)
seek_t = mid_chunks[0]["start"] + (mid_chunks[0]["end"] - mid_chunks[0]["start"]) / 2 if mid_chunks else dur / 2
seek_t = min(seek_t, dur - 0.1)
r = subprocess.run(
["ffmpeg", "-y",
"-ss", f"{seek_t:.2f}",
"-i", str(video_path),
"-vframes", "1",
"-vf", f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H}",
"-f", "image2pipe",
"-vcodec", "png", "-"],
capture_output=True, timeout=10
)
if r.returncode == 0 and r.stdout:
img = Image.open(io.BytesIO(r.stdout)).convert("RGB")
except Exception:
img = None
# ── fallback: רקע כהה גנרי אם אין סרטון ────────────────────────
if img is None:
img = Image.new("RGB", (W, H), (15, 15, 25))
# ── כלי ציור ──────────────────────────────────────────────────────
# שכבת כהייה עדינה כדי שהכתוביות יהיו קריאות על כל רקע
overlay = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ov_draw = ImageDraw.Draw(overlay)
draw = ImageDraw.Draw(img)
hook_rgb = tuple(color_to_rgb(hook_hex))
body_rgb = tuple(color_to_rgb(body_hex))
ol = max(1, int(outline_size))
def find_best_font(size):
candidates = [
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Bold.ttf",
"/usr/share/fonts/truetype/noto/NotoSerifHebrew-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/freefont/FreeSansBold.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
]
for path in candidates:
try:
return ImageFont.truetype(path, size), Path(path).stem
except Exception:
continue
return ImageFont.load_default(), "default"
hook_font, font_used = find_best_font(int(hook_size * 0.55))
body_font, _ = find_best_font(int(body_size * 0.55))
def draw_outlined(text, font, color, x, y):
# outline שחור סביב הטקסט — קריא על כל רקע
for dx in range(-ol, ol + 1):
for dy in range(-ol, ol + 1):
if dx or dy:
draw.text((x + dx, y + dy), text, font=font,
fill=(0, 0, 0), anchor="mm")
draw.text((x, y), text, font=font, fill=color, anchor="mm")
# ── מיקום כתוביות ─────────────────────────────────────────────────
POSITIONS = {"תחתית": 2, "מרכז": 5, "עליון": 8}
align = POSITIONS.get(position, 2)
if align == 8:
y_hook = int(H * 0.15)
y_body = int(H * 0.25)
elif align == 5:
y_hook = int(H * 0.45)
y_body = int(H * 0.57)
else:
y_hook = int(H * 0.75)
y_body = int(H * 0.85)
x = W // 2
if karaoke_mode:
# ── מצב Popup: מילה אחת גדולה ובולטת במרכז ─────────────────
# לוקחים את המילה הראשונה מהכתובית האמצעית
popup_word = hook_text.split()[0] if hook_text.split() else hook_text
popup_font, _ = find_best_font(int(hook_size * 0.85)) # גדול יותר מרגיל
popup_rgb = tuple(color_to_rgb(hook_hex))
draw_outlined(popup_word, popup_font, popup_rgb, x, y_hook)
# תווית הסבר קטנה
info_font, _ = find_best_font(11)
draw.text((x, y_hook + int(hook_size * 0.55) + 12),
"← מצב Pop-up: מילה אחת בכל פעם",
font=info_font, fill=(120, 100, 200), anchor="mm")
else:
draw_outlined(hook_text, hook_font, hook_rgb, x, y_hook)
if body_text:
draw_outlined(body_text, body_font, body_rgb, x, y_body)
# ── תווית "REAL PREVIEW" בפינה ────────────────────────────────────
badge_font, _ = find_best_font(11)
if karaoke_mode:
label = "✨ POPUP MODE"
elif video_path:
label = "📍 FRAME אמיתי"
else:
label = "🎨 PREVIEW גנרי"
# רקע כהה לתווית
bbox = draw.textbbox((6, H - 22), label, font=badge_font)
draw.rectangle([bbox[0]-4, bbox[1]-3, bbox[2]+4, bbox[3]+3],
fill=(0, 0, 0, 180))
draw.text((6, H - 22), label, font=badge_font, fill=(180, 180, 220))
return img
except ImportError:
return None
except Exception:
return None
def get_video_duration(video_path) -> float:
if video_path is None:
return 0.0
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", str(video_path)],
capture_output=True, text=True
)
return float(r.stdout.strip())
except Exception:
return 0.0
def on_video_upload(video_path):
if video_path is None:
return gr.update(), gr.update(), gr.update(value="", visible=False), gr.update(interactive=False)
# ── בדיקת גודל קובץ מיד אחרי העלאה ──────────────────────────────────
file_mb = Path(video_path).stat().st_size / 1024 / 1024
if file_mb > MAX_FILE_MB:
warning = (
f'<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": "כתום-אדום — אנרגטי ונועז"
},
}
# ─── מנתח וויראליות — חינמי, ללא API ────────────────────────────────────
HOOK_WORDS = [
"סוד", "חינם", "מטורף", "לא תאמינו", "שגיאה", "טיפ", "טריק", "הפתעה",
"מהיר", "קל", "פשוט", "חשוב", "דחוף", "בלעדי", "ראשון", "אחרון",
"כסף", "רווח", "הצלחה", "כישלון", "אמת", "שקר", "נסתר", "גלוי",
"למה", "איך", "מתי", "מה", "מי", "האמת", "הסוד", "הטעות",
]
def analyze_virality(segments: list, total_dur: float) -> str:
"""
מנתח את הסרטון ונותן ציון וויראליות + טיפים.
מחזיר HTML מעוצב לתצוגה.
"""
if not segments:
return ""
score = 50 # ציון בסיס
tips_good = []
tips_bad = []
all_text = " ".join(s.get("text", "") for s in segments)
first_text = segments[0].get("text", "") if segments else ""
last_text = segments[-1].get("text", "") if segments else ""
# ── 1. האם השורה הראשונה חזקה? ──────────────────────────────────────
has_hook = any(w in first_text for w in HOOK_WORDS)
has_question = "?" in first_text or any(q in first_text for q in ["למה", "איך", "מה ", "מי "])
if has_hook:
score += 15
tips_good.append("השורה הראשונה מכילה מילת עצירה חזקה 🎯")
elif has_question:
score += 10
tips_good.append("השורה הראשונה פותחת בשאלה — טוב לעצירת גלילה ✅")
else:
score -= 10
tips_bad.append("השורה הראשונה חלשה — נסה לפתוח עם שאלה או מילה מפתיעה")
# ── 2. אורך הסרטון ───────────────────────────────────────────────────
if total_dur <= 15:
score += 15
tips_good.append("סרטון קצר מאוד — מושלם לTikTok ✅")
elif total_dur <= 30:
score += 10
tips_good.append("אורך אידיאלי לReels ✅")
elif total_dur <= 60:
score += 0
tips_bad.append("סרטון בינוני — רוב הצופים יברחו אחרי 30 שניות")
else:
score -= 15
tips_bad.append("סרטון ארוך מדי — שקול לחתוך לקטע הכי חזק")
# ── 3. האם יש קריאה לפעולה בסוף? ────────────────────────────────────
cta_words = ["עקוב", "שתף", "לייק", "תגובה", "שמור", "שלח", "קנה", "הירשם", "לחץ"]
has_cta = any(w in last_text for w in cta_words)
if has_cta:
score += 10
tips_good.append("יש קריאה לפעולה בסוף ✅")
else:
tips_bad.append("חסרה קריאה לפעולה — הוסף 'שתף אם עזר' / 'עקוב לעוד'")
# ── 4. ממוצע מילים לכתובית ───────────────────────────────────────────
total_words = len(all_text.split())
avg_words = total_words / max(len(segments), 1)
if avg_words <= 4:
score += 5
tips_good.append("כתוביות קצרות וקריאות ✅")
elif avg_words > 7:
score -= 5
tips_bad.append("כתוביות ארוכות מדי — קשה לקרוא בגלילה")
# ── 5. מגוון — האם יש מילות מפתח לאורך הסרטון? ─────────────────────
hook_count = sum(1 for w in HOOK_WORDS if w in all_text)
if hook_count >= 3:
score += 5
tips_good.append("תוכן עשיר במילות מפתח לאורך הסרטון ✅")
score = max(0, min(100, score))
# ── צבע ציון ─────────────────────────────────────────────────────────
if score >= 75:
score_color = "#44ff88"
score_label = "וויראלי 🔥"
elif score >= 55:
score_color = "#f0c040"
score_label = "פוטנציאל טוב ⚡"
else:
score_color = "#ff6666"
score_label = "צריך שיפור 📈"
good_html = "".join(f'<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>"""
# ─── ממשק Gradio ─────────────────────────────────────────────────────────
with gr.Blocks(
title="HebSub ⚡",
theme=gr.themes.Base(primary_hue="yellow", neutral_hue="slate", font=gr.themes.GoogleFont("Heebo")),
css=CSS
) as demo:
gr.HTML(PWA_HTML)
gr.HTML(HEADER_HTML)
with gr.Group() as step1_group:
gr.HTML('<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="🖊️ עובי מסגרת")
# hook_lines ו-position עברו ל-accordion מיקום למטה
with gr.Accordion("🎵 מצב Pop-up (TikTok)", open=False, elem_classes="karaoke-box"):
karaoke_mode_v = gr.Checkbox(value=False, label="🎤 הפעל Pop-up — כל מילה קופצת בנפרד")
karaoke_color_v = gr.ColorPicker(value="#FFFF00", label="🌟 צבע הדגשה")
gr.Markdown('<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)
# ─── Events ──────────────────────────────────────────────────────────
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(
# שלב ד: waveform — רץ אחרון, לא מאט את התמלול
fn=run_waveform,
inputs=[video_in, segments_state],
outputs=waveform_img
).then(
# שלב ה: ניתוח וויראליות — רץ אחרי הwaveform
fn=lambda segs: (
gr.update(value=analyze_virality(segs, segs[-1]["end"] if segs else 0), visible=True)
if segs else gr.update(visible=False)
),
inputs=[segments_state],
outputs=virality_box
)
export_srt_btn.click(fn=export_srt, inputs=subtitle_table, outputs=export_srt_btn)
export_ass_btn.click(fn=export_ass, inputs=[subtitle_table, font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v], outputs=export_ass_btn)
karaoke_mode_v.change(fn=on_karaoke_toggle, inputs=karaoke_mode_v, outputs=karaoke_warning)
# ── סגנונות קסם ───────────────────────────────────────────────────────
def apply_style(style_name):
s = MAGIC_STYLES[style_name]
return (
gr.update(value=s["font"]),
gr.update(value=s["hook_color"]),
gr.update(value=s["body_color"]),
gr.update(value=s["hook_size"]),
gr.update(value=s["body_size"]),
gr.update(value=s["outline"]),
gr.update(value=s["position"]),
gr.update(value=f'<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
)
# ── פריוויו אוטומטי: טבלה — הוסר כדי לא להאט את התמלול
# (subtitle_table.change מופעל גם כשהתמלול ממלא את הטבלה — מחנק CPU)
# המשתמש יכול לעדכן ידנית עם כפתור "עדכן תצוגה מקדימה"
# ── פריוויו אוטומטי: עיצוב ──────────────────────────────────────────
# dropdown + colorpicker — .change בסדר (אירוע חד-פעמי)
for design_input in [font_sel_v, hook_color_v, body_color_v, position_v]:
design_input.change(
fn=generate_preview,
inputs=[font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v, subtitle_table, video_in, karaoke_mode_v],
outputs=preview_img
)
# sliders — רק כשמשחררים, לא בכל הזזה (מונע עומס CPU!)
for slider_input in [hook_size_v, body_size_v, outline_v]:
slider_input.release(
fn=generate_preview,
inputs=[font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, outline_v, position_v, hook_lines_v, subtitle_table, video_in, karaoke_mode_v],
outputs=preview_img
)
# ─── כפתור צריבה: נחסם מיד בלחיצה, חוזר אחרי סיום ─────────────────
# שלב א: השבת מיד בלחיצה (מונע לחיצה כפולה בטעות)
burn_btn.click(
fn=lambda: gr.update(value="⏳ צורב... אנא המתן", interactive=False),
inputs=[],
outputs=burn_btn
).then(
# שלב ב: מריץ את הצריבה עצמה, ומחזיר גם את מצב הכפתור
fn=do_burn,
inputs=[video_in, subtitle_table, font_sel_v, hook_size_v, body_size_v, hook_color_v, body_color_v, karaoke_color_v, outline_v, position_v, hook_lines_v, karaoke_mode_v, segments_state],
outputs=[video_out, status_out, download_btn, burn_btn, reset_btn]
)
# ─── כפתור סרטון חדש: מנקה וידאו + כתוביות, שומר עיצוב ────────────
def do_reset():
"""מנקה את הסרטון, הכתוביות והסטטוס — אבל שומר גופן, צבעים וגדלים."""
return (
None, # video_in
empty_df(), # subtitle_table
[], # segments_state
"", # status_out
None, # video_out
gr.update(visible=False), # download_btn
gr.update(visible=False), # reset_btn
gr.update(visible=False), # step3_group
gr.update(visible=False), # step4_group
gr.update(value="", visible=False), # time_estimate_box
gr.update(interactive=False), # transcribe_btn
gr.update(value="🔥 צרוב כתוביות לסרטון", interactive=False), # burn_btn
gr.update(value=None, visible=False), # waveform_img
gr.update(value="", visible=False), # upload_warning
gr.update(value="", visible=False), # style_label
gr.update(value="", visible=False), # virality_box
)
reset_btn.click(
fn=do_reset,
inputs=[],
outputs=[
video_in, subtitle_table, segments_state, status_out,
video_out, download_btn, reset_btn,
step3_group, step4_group,
time_estimate_box, transcribe_btn, burn_btn, waveform_img,
upload_warning, style_label, virality_box,
]
)
if __name__ == "__main__":
demo.launch(share=False)