| """ |
| DubFlow Web v2.4 — Real-time + REST API + Fixed Pipe Handling |
| """ |
|
|
| from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Query, BackgroundTasks |
| from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse, JSONResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| from fastapi.middleware.gzip import GZipMiddleware |
|
|
| import asyncio, subprocess, uuid, os, re, json, time, shutil, hashlib, logging |
| from collections import OrderedDict |
| from contextlib import asynccontextmanager |
| from concurrent.futures import ThreadPoolExecutor |
| from typing import Optional |
|
|
| import aiohttp, aiofiles, httpx, edge_tts |
|
|
| logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') |
| log = logging.getLogger("dubflow") |
|
|
| TMP = "/tmp" |
| PUBLIC = "/tmp/public" |
| os.makedirs(PUBLIC, exist_ok=True) |
|
|
| FFMPEG = shutil.which("ffmpeg") or "ffmpeg" |
| FFPROBE = shutil.which("ffprobe") or "ffprobe" |
|
|
| TTS_CONCURRENCY = 12 |
| TRANS_CONCURRENCY = 20 |
| FFMPEG_THREADS = 6 |
| CHUNK_SIZE = 1024 * 512 |
|
|
| MAX_UPLOAD_MB = 500 |
| MIN_FILE_BYTES = 1000 |
| FILE_TTL_SECONDS = 3600 |
|
|
| STEP_WEIGHTS = {0: 3, 1: 35, 2: 12, 3: 35, 4: 15} |
|
|
| JOBS: dict = {} |
| THREAD_POOL = ThreadPoolExecutor(max_workers=32) |
|
|
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| task = asyncio.create_task(periodic_cleanup()) |
| log.info("DubFlow v2.6.0 started") |
| yield |
| task.cancel() |
| THREAD_POOL.shutdown(wait=False) |
|
|
|
|
| app = FastAPI( |
| title="DubFlow API", |
| version="2.6.0", |
| description="Video dubbing pipeline — MP4 → SRT → Translate → TTS → Dubbed MP4", |
| lifespan=lifespan, |
| ) |
| app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) |
| app.add_middleware(GZipMiddleware, minimum_size=1000) |
|
|
|
|
| async def periodic_cleanup(): |
| while True: |
| try: |
| await asyncio.sleep(600) |
| now = time.time() |
| for fn in os.listdir(PUBLIC): |
| p = os.path.join(PUBLIC, fn) |
| try: |
| if now - os.path.getmtime(p) > FILE_TTL_SECONDS: |
| os.remove(p) |
| except: pass |
| dead = [k for k, v in JOBS.items() |
| if v.get("done") and now - v.get("created", now) > FILE_TTL_SECONDS] |
| for k in dead: JOBS.pop(k, None) |
| except asyncio.CancelledError: break |
| except Exception as e: log.error(f"cleanup: {e}") |
|
|
|
|
| |
| |
| |
| VOICES = { |
| ("af","Female"):"af-ZA-AdriNeural", ("af","Male"):"af-ZA-WillemNeural", |
| ("sq","Female"):"sq-AL-AnilaNeural", ("sq","Male"):"sq-AL-IlirNeural", |
| ("ar","Female"):"ar-SA-ZariyahNeural", ("ar","Male"):"ar-SA-HamedNeural", |
| ("bg","Female"):"bg-BG-KalinaNeural", ("bg","Male"):"bg-BG-BorislavNeural", |
| ("zh","Female"):"zh-CN-XiaoxiaoNeural", ("zh","Male"):"zh-CN-YunxiNeural", |
| ("hr","Female"):"hr-HR-GabrijelaNeural", ("hr","Male"):"hr-HR-SreckoNeural", |
| ("cs","Female"):"cs-CZ-VlastaNeural", ("cs","Male"):"cs-CZ-AntoninNeural", |
| ("da","Female"):"da-DK-ChristelNeural", ("da","Male"):"da-DK-JeppeNeural", |
| ("nl","Female"):"nl-NL-ColetteNeural", ("nl","Male"):"nl-NL-MaartenNeural", |
| ("en","Female"):"en-US-AriaNeural", ("en","Male"):"en-US-GuyNeural", |
| ("fi","Female"):"fi-FI-NooraNeural", ("fi","Male"):"fi-FI-HarriNeural", |
| ("fr","Female"):"fr-FR-DeniseNeural", ("fr","Male"):"fr-FR-HenriNeural", |
| ("de","Female"):"de-DE-AmalaNeural", ("de","Male"):"de-DE-ConradNeural", |
| ("el","Female"):"el-GR-AthinaNeural", ("el","Male"):"el-GR-NestorasNeural", |
| ("he","Female"):"he-IL-HilaNeural", ("he","Male"):"he-IL-AvriNeural", |
| ("hi","Female"):"hi-IN-SwaraNeural", ("hi","Male"):"hi-IN-MadhurNeural", |
| ("hu","Female"):"hu-HU-NoemiNeural", ("hu","Male"):"hu-HU-TamasNeural", |
| ("id","Female"):"id-ID-GadisNeural", ("id","Male"):"id-ID-ArdiNeural", |
| ("it","Female"):"it-IT-IsabellaNeural", ("it","Male"):"it-IT-DiegoNeural", |
| ("ja","Female"):"ja-JP-NanamiNeural", ("ja","Male"):"ja-JP-KeitaNeural", |
| ("ko","Female"):"ko-KR-SunHiNeural", ("ko","Male"):"ko-KR-InJoonNeural", |
| ("ms","Female"):"ms-MY-YasminNeural", ("ms","Male"):"ms-MY-OsmanNeural", |
| ("nb","Female"):"nb-NO-PernilleNeural", ("nb","Male"):"nb-NO-FinnNeural", |
| ("fa","Female"):"fa-IR-DilaraNeural", ("fa","Male"):"fa-IR-FaridNeural", |
| ("pl","Female"):"pl-PL-ZofiaNeural", ("pl","Male"):"pl-PL-MarekNeural", |
| ("pt","Female"):"pt-BR-FranciscaNeural", ("pt","Male"):"pt-BR-AntonioNeural", |
| ("ro","Female"):"ro-RO-AlinaNeural", ("ro","Male"):"ro-RO-EmilNeural", |
| ("ru","Female"):"ru-RU-SvetlanaNeural", ("ru","Male"):"ru-RU-DmitryNeural", |
| ("sk","Female"):"sk-SK-ViktoriaNeural", ("sk","Male"):"sk-SK-LukasNeural", |
| ("es","Female"):"es-ES-ElviraNeural", ("es","Male"):"es-ES-AlvaroNeural", |
| ("sv","Female"):"sv-SE-SofieNeural", ("sv","Male"):"sv-SE-MattiasNeural", |
| ("th","Female"):"th-TH-PremwadeeNeural", ("th","Male"):"th-TH-NiwatNeural", |
| ("tr","Female"):"tr-TR-EmelNeural", ("tr","Male"):"tr-TR-AhmetNeural", |
| ("uk","Female"):"uk-UA-PolinaNeural", ("uk","Male"):"uk-UA-OstapNeural", |
| ("vi","Female"):"vi-VN-HoaiMyNeural", ("vi","Male"):"vi-VN-NamMinhNeural", |
| } |
|
|
| LANGUAGES = OrderedDict([ |
| ("it","Italiano"),("en","English"),("es","Español"),("fr","Français"), |
| ("de","Deutsch"),("pt","Português"),("ru","Русский"),("zh","中文"), |
| ("ja","日本語"),("ko","한국어"),("ar","العربية"),("hi","हिन्दी"), |
| ("tr","Türkçe"),("pl","Polski"),("nl","Nederlands"),("sv","Svenska"), |
| ("da","Dansk"),("fi","Suomi"),("nb","Norsk"),("el","Ελληνικά"), |
| ("cs","Čeština"),("ro","Română"),("hu","Magyar"),("uk","Українська"), |
| ("id","Indonesia"),("th","ไทย"),("vi","Tiếng Việt"),("he","עברית"), |
| ("bg","Български"),("hr","Hrvatski"),("sk","Slovenčina"),("sq","Shqip"), |
| ("af","Afrikaans"),("ms","Melayu"),("fa","فارسی"), |
| ]) |
|
|
|
|
| |
| |
| |
| def new_job(): |
| jid = uuid.uuid4().hex |
| JOBS[jid] = { |
| "stage": "init", "message": "In coda", |
| "step": 0, "step_progress": 0.0, "progress": 0.0, |
| "result": None, "done": False, "error": None, |
| "cancel": False, "created": time.time(), |
| "video_ready": False, "video_path": None, "params": None, |
| "last_heartbeat": time.time(), |
| "original_name": "video", |
| } |
| log.info(f"[{jid[:8]}] job created") |
| return jid |
|
|
| def compute_global(step, step_pct): |
| """Calcolo progress globale con precisione decimale.""" |
| done = sum(STEP_WEIGHTS[s] for s in STEP_WEIGHTS if s < step) |
| if step in STEP_WEIGHTS: |
| done += STEP_WEIGHTS[step] * (step_pct / 100.0) |
| return round(min(100.0, done), 2) |
|
|
| def update(jid, **kw): |
| j = JOBS.get(jid) |
| if not j: return |
| j.update(kw) |
| if "step" in kw or "step_progress" in kw: |
| j["progress"] = round(compute_global(j["step"], j["step_progress"]), 2) |
| j["last_heartbeat"] = time.time() |
|
|
| def is_cancelled(jid): |
| return JOBS.get(jid, {}).get("cancel", False) |
|
|
| def cleanup_paths(paths): |
| for p in paths: |
| try: |
| if not p: continue |
| if os.path.isdir(p): shutil.rmtree(p, ignore_errors=True) |
| elif os.path.isfile(p): os.remove(p) |
| except: pass |
|
|
|
|
| |
| |
| |
| _TS_RE = re.compile(r'(\d{1,2}:\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[.,]\d{3})') |
| _BLANK_RE = re.compile(r'\n\s*\n') |
| _IDX_RE = re.compile(r'^\d+\s*$') |
|
|
| def ts_to_sec(ts): |
| ts = ts.strip().replace(",", ".") |
| parts = ts.split(":") |
| if len(parts) == 3: |
| return int(parts[0])*3600 + int(parts[1])*60 + float(parts[2]) |
| if len(parts) == 2: |
| return int(parts[0])*60 + float(parts[1]) |
| return float(ts) |
|
|
| def parse_srt(content): |
| blocks = [] |
| content = content.replace('\r\n','\n').replace('\r','\n').lstrip('\ufeff') |
| for raw in _BLANK_RE.split(content.strip()): |
| lines = raw.strip().split('\n') |
| if len(lines) < 2 or not _IDX_RE.match(lines[0].strip()): |
| continue |
| ts_m = _TS_RE.match(lines[1].strip()) |
| if not ts_m: continue |
| text = '\n'.join(lines[2:]).strip() |
| if text: |
| blocks.append({ |
| "index": int(lines[0].strip()), |
| "timestamp": lines[1].strip(), |
| "start_sec": ts_to_sec(ts_m.group(1)), |
| "end_sec": ts_to_sec(ts_m.group(2)), |
| "text": text, |
| }) |
| return blocks |
|
|
| def build_srt(blocks): |
| return '\n'.join(f"{i}\n{b['timestamp']}\n{b['text']}\n" for i, b in enumerate(blocks, 1)) |
|
|
| _CLEAN_PATTERNS = [ |
| (re.compile(r'<[^>]+>'), ''), |
| (re.compile(r'\{[^}]+\}'), ''), |
| (re.compile(r'♪[^♪]*♪'), ''), (re.compile(r'♪'), ''), |
| (re.compile(r'\[.*?\]'), ''), (re.compile(r'\(.*?\)'), ''), |
| (re.compile(r'-{2,}'), ''), (re.compile(r'\s+'), ' '), |
| ] |
|
|
| def clean_text(text): |
| for pat, rep in _CLEAN_PATTERNS: |
| text = pat.sub(rep, text) |
| return text.strip() |
|
|
|
|
| |
| |
| |
| async def get_video_duration_async(path): |
| try: |
| proc = await asyncio.create_subprocess_exec( |
| FFPROBE, "-v", "error", "-show_entries", "format=duration", |
| "-of", "default=noprint_wrappers=1:nokey=1", path, |
| stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) |
| out, _ = await proc.communicate() |
| return float(out.decode().strip() or 0) |
| except: return 0.0 |
|
|
| async def validate_video(path): |
| cmd = [FFPROBE, "-v", "error", "-select_streams", "v:0", |
| "-show_entries", "stream=codec_name,duration", "-of", "json", path] |
| proc = await asyncio.create_subprocess_exec( |
| *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) |
| out, err = await proc.communicate() |
| if proc.returncode != 0: |
| raise RuntimeError(f"Video non valido: {err.decode()[:200]}") |
| try: |
| info = json.loads(out.decode() or "{}") |
| if not info.get("streams"): |
| raise RuntimeError("Nessun flusso video") |
| except json.JSONDecodeError: |
| raise RuntimeError("Output ffprobe non valido") |
|
|
|
|
| async def run_ffmpeg_with_progress(cmd, duration, jid, base_pct, span_pct, msg_prefix=""): |
| """ |
| Esegue ffmpeg con -progress pipe:1 leggendo stdout/stderr in parallelo. |
| Evita race condition di communicate() + manual read. |
| """ |
| proc = await asyncio.create_subprocess_exec( |
| *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) |
| |
| stderr_buf = bytearray() |
| |
| async def read_stdout(): |
| try: |
| while True: |
| line = await proc.stdout.readline() |
| if not line: break |
| s = line.decode(errors="ignore").strip() |
| if s.startswith("out_time_ms=") and duration > 0: |
| try: |
| us = int(s.split("=", 1)[1]) |
| sec = us / 1_000_000 |
| pct = base_pct + min(span_pct, (sec/duration) * span_pct) |
| update(jid, step_progress=round(pct, 2), |
| message=f"{msg_prefix}{sec:.1f}/{duration:.1f}s") |
| except (ValueError, IndexError): |
| pass |
| except Exception as e: |
| log.debug(f"read_stdout: {e}") |
| |
| async def read_stderr(): |
| try: |
| while True: |
| chunk = await proc.stderr.read(4096) |
| if not chunk: break |
| stderr_buf.extend(chunk) |
| except Exception as e: |
| log.debug(f"read_stderr: {e}") |
| |
| |
| await asyncio.gather(read_stdout(), read_stderr(), proc.wait()) |
| |
| if proc.returncode != 0: |
| err = stderr_buf.decode(errors="ignore")[-500:] |
| raise RuntimeError(f"FFmpeg failed ({proc.returncode}): {err}") |
|
|
|
|
| |
| |
| |
| BASE_URL = "https://ca1.converter.app/mp4-to-srt" |
| SITE_URL = "https://converter.app" |
| HEADERS_WEB = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36", |
| "Referer": f"{SITE_URL}/", "Origin": SITE_URL, |
| } |
|
|
| def looks_like_srt(text): |
| return bool(text and len(text.strip()) >= 10 and _TS_RE.search(text)) |
|
|
| def extract_srt_from_html(html): |
| """Estrae SRT da HTML con strategie multiple.""" |
| if not html: return None |
| import html as _html |
| |
| |
| |
| decoded = _html.unescape(html) |
| |
| |
| srt_pattern = re.compile( |
| r'(\d+\s*\n\s*\d{1,2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*' |
| r'\d{1,2}:\d{2}:\d{2}[.,]\d{3}.*?)(?=(?:\n\s*\n\s*\d+\s*\n|\Z))', |
| re.DOTALL |
| ) |
| matches = srt_pattern.findall(decoded) |
| if matches: |
| |
| joined = "\n\n".join(m.strip() for m in matches) |
| if looks_like_srt(joined): |
| return joined |
| |
| |
| for tag in ["textarea", "pre", "code"]: |
| for m in re.finditer( |
| rf'<{tag}[^>]*>(.*?)</{tag}>', html, re.S | re.I): |
| content = _html.unescape(m.group(1)).strip() |
| if looks_like_srt(content): |
| return content |
| |
| extracted = _extract_srt_lines(content) |
| if extracted: |
| return extracted |
| |
| |
| for m in re.finditer(r'<script[^>]*>(.*?)</script>', html, re.S | re.I): |
| script = m.group(1) |
| |
| for sm in re.finditer(r'["\']([^"\']*\d{1,2}:\d{2}:\d{2}[.,]\d{3}\s*-->[^"\']*)["\']', script): |
| txt = _html.unescape(sm.group(1)).replace("\\n", "\n").replace("\\r", "") |
| if looks_like_srt(txt): |
| return txt |
| |
| |
| extracted = _extract_srt_lines(decoded) |
| if extracted: |
| return extracted |
| |
| return None |
|
|
|
|
| def _extract_srt_lines(text): |
| """Cerca tutte le righe timestamp e i loro contenuti.""" |
| if not text: return None |
| lines = text.split("\n") |
| blocks = [] |
| current = [] |
| in_block = False |
| |
| ts_re = re.compile(r'^\s*\d{1,2}:\d{2}:\d{2}[.,]\d{3}\s*-->\s*\d{1,2}:\d{2}:\d{2}[.,]\d{3}') |
| idx_re = re.compile(r'^\s*\d+\s*$') |
| |
| for line in lines: |
| if ts_re.match(line): |
| |
| if current and len(current) >= 2: |
| blocks.append("\n".join(current)) |
| |
| prev_idx = str(len(blocks) + 1) |
| current = [prev_idx, line.strip()] |
| in_block = True |
| elif in_block: |
| stripped = line.strip() |
| if not stripped: |
| if len(current) >= 3: |
| blocks.append("\n".join(current)) |
| current = [] |
| in_block = False |
| elif idx_re.match(stripped) and len(current) >= 3: |
| |
| blocks.append("\n".join(current)) |
| current = [] |
| in_block = False |
| else: |
| |
| clean = re.sub(r'<[^>]+>', '', stripped).strip() |
| if clean: |
| current.append(clean) |
| |
| if current and len(current) >= 3: |
| blocks.append("\n".join(current)) |
| |
| if not blocks: return None |
| |
| |
| out = [] |
| for i, b in enumerate(blocks, 1): |
| parts = b.split("\n") |
| if len(parts) >= 2: |
| out.append(f"{i}\n" + "\n".join(parts[1:])) |
| |
| result = "\n\n".join(out) |
| return result if looks_like_srt(result) else None |
|
|
| def extract_url_from_html(html): |
| for pat in [ |
| r'href=["\']([^"\']*\.srt[^"\']*)["\']', |
| r'href=["\']([^"\']*download[^"\']*)["\']', |
| r'(https?://[^\s"\'<>]*\.srt[^\s"\'<>]*)', |
| ]: |
| m = re.search(pat, html, re.I) |
| if m: return m.group(1) |
| return None |
|
|
| async def extract_audio_with_progress(input_path, jid, base_pct=0, span_pct=15): |
| """ |
| Estrae solo audio mantenendo codec originale (-acodec copy). |
| Output .mp4 container per compatibilità con converter.app. |
| """ |
| out = os.path.join(TMP, f"dub_audio_{uuid.uuid4().hex}.mp4") |
| duration = await get_video_duration_async(input_path) |
| log.info(f"[{jid[:8]}] video duration: {duration:.1f}s") |
| |
| update(jid, step_progress=base_pct, message="Estrazione audio (stream copy)…") |
| |
| |
| cmd = [FFMPEG, "-y", "-i", input_path, |
| "-vn", "-acodec", "copy", "-movflags", "+faststart", |
| "-progress", "pipe:1", "-nostats", out] |
| |
| proc = await asyncio.create_subprocess_exec( |
| *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) |
| |
| stderr_buf = bytearray() |
| |
| async def read_stdout(): |
| try: |
| while True: |
| line = await proc.stdout.readline() |
| if not line: break |
| s = line.decode(errors="ignore").strip() |
| if s.startswith("out_time_ms=") and duration > 0: |
| try: |
| us = int(s.split("=", 1)[1]) |
| sec = us / 1_000_000 |
| pct = base_pct + min(span_pct, (sec/duration) * span_pct) |
| actual_pct = sec/duration*100 |
| update(jid, step_progress=round(pct, 2), |
| message=f"Audio extract {actual_pct:.2f}% ({sec:.2f}/{duration:.2f}s)") |
| except (ValueError, IndexError): |
| pass |
| except Exception: |
| pass |
| |
| async def read_stderr(): |
| try: |
| while True: |
| chunk = await proc.stderr.read(4096) |
| if not chunk: break |
| stderr_buf.extend(chunk) |
| except Exception: |
| pass |
| |
| await asyncio.gather(read_stdout(), read_stderr(), proc.wait()) |
| |
| if proc.returncode != 0: |
| |
| log.warning(f"[{jid[:8]}] stream copy failed, trying AAC encode") |
| err = stderr_buf.decode(errors="ignore")[-300:] |
| log.warning(f"[{jid[:8]}] stream copy err: {err}") |
| |
| cmd2 = [FFMPEG, "-y", "-i", input_path, "-vn", |
| "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", out] |
| proc2 = await asyncio.create_subprocess_exec( |
| *cmd2, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) |
| _, err2 = await proc2.communicate() |
| if proc2.returncode != 0: |
| raise RuntimeError(f"Audio extract failed: {err2.decode()[:300]}") |
| |
| size_mb = os.path.getsize(out) / 1048576 |
| log.info(f"[{jid[:8]}] audio extracted: {size_mb:.1f} MB") |
| update(jid, step_progress=base_pct + span_pct, |
| message=f"Audio pronto ({size_mb:.1f} MB)") |
| return out |
|
|
| async def fetch_srt(session, jobid, server_filename=None, jid_log=""): |
| """Versione semplificata + robusta basata sulla desktop funzionante.""" |
| out_name = None |
| if server_filename: |
| out_name = server_filename.rsplit(".", 1)[0] + ".srt" |
| |
| candidates = [ |
| f"{SITE_URL}/mp4-to-srt/result.php?jobid={jobid}&lang=en&w=ca1", |
| f"{SITE_URL}/mp4-to-srt/result.php?jobid={jobid}", |
| f"{BASE_URL}/result.php?jobid={jobid}&lang=en&w=ca1", |
| f"{BASE_URL}/result.php?jobid={jobid}", |
| f"{BASE_URL}/download.php?jobid={jobid}", |
| ] |
| if out_name: |
| candidates.append(f"{BASE_URL}/download.php?jobid={jobid}&file={out_name}") |
| |
| req_timeout = aiohttp.ClientTimeout(total=10, sock_connect=5, sock_read=8) |
| |
| for idx, url in enumerate(candidates): |
| try: |
| log.info(f"[{jid_log}] fetch_srt try {idx+1}/{len(candidates)}: ...{url[-60:]}") |
| async with session.get(url, allow_redirects=True, timeout=req_timeout) as resp: |
| if resp.status not in (200, 206): |
| log.info(f"[{jid_log}] → HTTP {resp.status}") |
| continue |
| raw_bytes = await resp.read() |
| text = raw_bytes.decode("utf-8", errors="ignore").strip() |
| log.info(f"[{jid_log}] → {len(text)} bytes, ctype={resp.headers.get('content-type','?')[:40]}") |
| |
| if looks_like_srt(text): |
| log.info(f"[{jid_log}] ✅ SRT diretto!") |
| return text |
| |
| |
| emb = extract_srt_from_html(text) |
| if emb: |
| log.info(f"[{jid_log}] ✅ SRT estratto da HTML ({len(emb)} chars)") |
| return emb |
| |
| |
| dl = extract_url_from_html(text) |
| if dl: |
| from urllib.parse import urljoin |
| dl = urljoin(str(resp.url), dl) |
| log.info(f"[{jid_log}] → follow link: ...{dl[-60:]}") |
| try: |
| async with session.get(dl, allow_redirects=True, timeout=req_timeout) as dlr: |
| if dlr.status == 200: |
| t2 = (await dlr.read()).decode("utf-8", errors="ignore") |
| if looks_like_srt(t2): |
| log.info(f"[{jid_log}] ✅ SRT da link!") |
| return t2 |
| except Exception as e2: |
| log.info(f"[{jid_log}] link fail: {type(e2).__name__}") |
| else: |
| |
| preview = re.sub(r'\s+', ' ', text[:300]) |
| log.info(f"[{jid_log}] preview: {preview}") |
| except asyncio.TimeoutError: |
| log.info(f"[{jid_log}] ⏱ timeout") |
| continue |
| except Exception as e: |
| log.info(f"[{jid_log}] ✗ {type(e).__name__}: {str(e)[:80]}") |
| continue |
| |
| log.warning(f"[{jid_log}] fetch_srt: nessun candidato ha funzionato") |
| return None |
|
|
| async def step1_extract_srt(video_path, jid): |
| update(jid, step=1, stage="extract", step_progress=0, message="Inizio…") |
| audio_path = await extract_audio_with_progress(video_path, jid, 0, 15) |
| |
| try: |
| file_size = os.path.getsize(audio_path) |
| |
| |
| filename = os.path.basename(video_path) |
| if not filename.lower().endswith(('.mp4', '.mkv', '.avi', '.mov', '.webm')): |
| filename = filename + ".mp4" |
| |
| log.info(f"[{jid[:8]}] uploading to converter.app: {file_size/1048576:.1f} MB as '{filename}'") |
| |
| |
| boundary = f"----WebKitFormBoundary{os.urandom(8).hex()}" |
| pre = (f"--{boundary}\r\n" |
| f'Content-Disposition: form-data; name="files[]"; filename="{filename}"\r\n' |
| f"Content-Type: video/mp4\r\n\r\n").encode() |
| post = (f"\r\n--{boundary}\r\n" |
| f'Content-Disposition: form-data; name="ajax"\r\n\r\n' |
| f"1\r\n--{boundary}--\r\n").encode() |
| total = len(pre) + file_size + len(post) |
|
|
| async def sender(): |
| yield pre |
| async with aiofiles.open(audio_path, "rb") as f: |
| sent = len(pre) |
| last_t = 0 |
| while True: |
| chunk = await f.read(CHUNK_SIZE) |
| if not chunk: break |
| sent += len(chunk) |
| now = time.time() |
| if now - last_t > 0.1: |
| pct = 15 + (sent/total) * 25 |
| update(jid, step_progress=round(pct, 2), |
| message=f"Upload audio {sent/1048576:.1f}/{total/1048576:.1f} MB") |
| last_t = now |
| yield chunk |
| yield post |
|
|
| headers = {**HEADERS_WEB, |
| "Content-Type": f"multipart/form-data; boundary={boundary}", |
| "Content-Length": str(total)} |
| |
| upload_timeout = aiohttp.ClientTimeout(total=600, sock_connect=20, sock_read=120) |
| poll_timeout = aiohttp.ClientTimeout(total=20, sock_connect=10, sock_read=15) |
| connector = aiohttp.TCPConnector(limit=10, ttl_dns_cache=300) |
|
|
| async with aiohttp.ClientSession(connector=connector, headers=HEADERS_WEB) as session: |
| log.info(f"[{jid[:8]}] POST uploader.php") |
| try: |
| async with session.post(f"{BASE_URL}/uploader.php", |
| data=sender(), headers=headers, |
| timeout=upload_timeout) as resp: |
| if resp.status != 200: |
| raise Exception(f"Upload HTTP {resp.status}") |
| upload_text = await resp.text() |
| log.info(f"[{jid[:8]}] upload response: {upload_text[:300]}") |
| except asyncio.TimeoutError: |
| raise Exception("Timeout upload converter.app") |
|
|
| update(jid, step_progress=42, message="Server in elaborazione…") |
|
|
| jobid = server_filename = None |
| try: |
| data = json.loads(upload_text) |
| jobid = data.get("jobid") |
| server_filename = data.get("filename") |
| if not jobid and data.get("files"): |
| fi = data["files"][0] |
| jobid = fi.get("jobid") |
| server_filename = fi.get("name", fi.get("filename")) |
| if not jobid and "url" in fi: |
| m = re.search(r"jobid=([a-zA-Z0-9]+)", fi["url"]) |
| jobid = m.group(1) if m else None |
| except: pass |
| if not jobid: |
| m = re.search(r'jobid[=:"\s]+([a-zA-Z0-9]+)', upload_text) |
| jobid = m.group(1) if m else None |
| if not jobid: |
| raise Exception(f"No jobid received: {upload_text[:200]}") |
|
|
| log.info(f"[{jid[:8]}] remote jobid: {jobid}, server filename: {server_filename}") |
| update(jid, message=f"Job remoto {jobid[:8]}…") |
| |
| try: |
| async with session.post(f"{BASE_URL}/process.php?jobid={jobid}", |
| timeout=poll_timeout) as r: |
| await r.text() |
| except: pass |
|
|
| STATUS = {0:5, 1:10, 2:40, 3:60, 4:80, 5:100, 6:100, |
| 100:100, 101:100, 200:100, 99:-1} |
| ts = int(time.time()*1000) |
| poll = 1.0 |
| last_pct = -1 |
| last_change_t = time.time() |
| poll_errors = 0 |
| |
| for i in range(1500): |
| if is_cancelled(jid): raise InterruptedError("cancelled") |
| await asyncio.sleep(poll) |
| |
| if i % 5 == 0: |
| log.info(f"[{jid[:8]}] poll #{i} (last_pct={last_pct}, errors={poll_errors})") |
| |
| raw = None |
| try: |
| async with session.get( |
| f"{BASE_URL}/display.php?jobid={jobid}&_={ts+i}", |
| timeout=poll_timeout) as r: |
| raw = (await r.text()).strip() |
| poll_errors = 0 |
| except asyncio.TimeoutError: |
| poll_errors += 1 |
| log.warning(f"[{jid[:8]}] poll timeout #{poll_errors}") |
| if poll_errors >= 5 and last_pct >= 60: |
| break |
| continue |
| except Exception as e: |
| poll_errors += 1 |
| if poll_errors >= 10: |
| raise Exception(f"Troppi errori polling: {e}") |
| continue |
| |
| try: code = int(json.loads(raw)) |
| except: |
| log.warning(f"[{jid[:8]}] bad poll response: {raw[:100]}") |
| continue |
| |
| if code in STATUS: |
| pct = STATUS[code] |
| elif code >= 5: |
| pct = 100 |
| elif code < 0: |
| pct = -1 |
| else: |
| pct = min(max(code * 20, 5), 90) |
| |
| if pct == -1: raise Exception(f"Server code={code}") |
| |
| step_pct = 45 + (pct/100) * 50 |
| update(jid, step_progress=round(step_pct, 2), |
| message=f"Trascrizione: {pct}% (poll #{i})") |
| |
| if pct != last_pct: |
| log.info(f"[{jid[:8]}] transcribe {pct}% (code={code})") |
| last_pct = pct |
| last_change_t = time.time() |
| else: |
| stuck = time.time() - last_change_t |
| if stuck > 120 and last_pct >= 60: |
| log.warning(f"[{jid[:8]}] stuck at {last_pct}% for {stuck:.0f}s, forcing fetch") |
| break |
| |
| poll = 0.6 if pct < 80 else (1.0 if pct < 100 else 0.5) |
| |
| if pct >= 100: |
| log.info(f"[{jid[:8]}] reached 100%, fetching SRT") |
| await asyncio.sleep(0.5) |
| break |
| |
| update(jid, step_progress=98, message="Recupero SRT…") |
| log.info(f"[{jid[:8]}] starting SRT recovery loop (30 attempts)") |
| srt = None |
| for attempt in range(30): |
| if is_cancelled(jid): raise InterruptedError("cancelled") |
| log.info(f"[{jid[:8]}] === SRT recovery attempt {attempt+1}/30 ===") |
| try: |
| srt = await asyncio.wait_for( |
| fetch_srt(session, jobid, server_filename, jid[:8]), |
| timeout=60) |
| if srt: break |
| except asyncio.TimeoutError: |
| log.warning(f"[{jid[:8]}] fetch_srt timeout attempt {attempt+1}") |
| except Exception as e: |
| log.warning(f"[{jid[:8]}] fetch_srt err: {e}") |
| update(jid, message=f"Recupero SRT tentativo {attempt+1}/30…") |
| await asyncio.sleep(1.5) |
| |
| if not srt: |
| raise Exception("SRT non recuperabile dopo 30 tentativi") |
| |
| log.info(f"[{jid[:8]}] SRT ok: {len(srt)} chars") |
| update(jid, step_progress=100, message=f"SRT estratto ({len(srt)} char)") |
| return srt |
| finally: |
| if os.path.exists(audio_path): |
| try: os.remove(audio_path) |
| except: pass |
|
|
|
|
| |
| |
| |
| class TransCache: |
| __slots__ = ("_c", "_max") |
| def __init__(self, max_size=2000): |
| self._c = OrderedDict(); self._max = max_size |
| def get(self, t, tg): |
| k = hashlib.md5(f"{tg}|{t}".encode()).digest() |
| v = self._c.get(k) |
| if v is not None: self._c.move_to_end(k) |
| return v |
| def put(self, t, tg, r): |
| if r.strip() == t.strip(): return |
| k = hashlib.md5(f"{tg}|{t}".encode()).digest() |
| self._c[k] = r |
| while len(self._c) > self._max: self._c.popitem(last=False) |
|
|
| _CACHE = TransCache() |
|
|
| async def _t_google(client, text, tgt): |
| r = await client.get("https://translate.googleapis.com/translate_a/single", |
| params={"client":"gtx","sl":"auto","tl":tgt,"dt":"t","q":text}, timeout=15) |
| r.raise_for_status() |
| data = r.json() |
| out = "" |
| if isinstance(data, list) and data[0]: |
| for c in data[0]: |
| if c and c[0]: out += c[0] |
| if out: return out |
| raise RuntimeError("empty") |
|
|
| async def _t_mymemory(client, text, tgt): |
| r = await client.get("https://api.mymemory.translated.net/get", |
| params={"q": text[:500], "langpair": f"auto|{tgt}"}, timeout=15) |
| r.raise_for_status() |
| d = r.json() |
| if d.get("responseStatus") == 200: |
| out = d["responseData"]["translatedText"] |
| if out and "MYMEMORY WARNING" not in out: return out |
| raise RuntimeError("fail") |
|
|
| async def _t_libre(client, text, tgt): |
| for base in ["https://libretranslate.com", "https://translate.argosopentech.com"]: |
| try: |
| r = await client.post(f"{base}/translate", |
| json={"q": text[:1000], "source":"auto", "target":tgt, "format":"text"}, timeout=10) |
| if r.status_code == 200: |
| out = r.json().get("translatedText", "") |
| if out: return out |
| except: continue |
| raise RuntimeError("fail") |
|
|
| ASYNC_APIS = [_t_google, _t_mymemory, _t_libre] |
|
|
| async def translate_one(client, text, tgt): |
| if not text or not text.strip(): return text |
| text = text.strip() |
| c = _CACHE.get(text, tgt) |
| if c: return c |
| for fn in ASYNC_APIS: |
| try: |
| r = (await fn(client, text, tgt)).strip() |
| if not r: continue |
| if re.sub(r'\s+', ' ', text.lower()) == re.sub(r'\s+', ' ', r.lower()): continue |
| _CACHE.put(text, tgt, r) |
| return r |
| except: continue |
| return text |
|
|
| async def step2_translate(srt_content, target_lang, jid): |
| update(jid, step=2, stage="translate", step_progress=0, message=f"Traduzione → {target_lang}") |
| blocks = parse_srt(srt_content) |
| if not blocks: raise Exception("SRT vuoto") |
| total = len(blocks) |
| log.info(f"[{jid[:8]}] translating {total} blocks → {target_lang}") |
| done = [0] |
| sem = asyncio.Semaphore(TRANS_CONCURRENCY) |
| lock = asyncio.Lock() |
|
|
| async with httpx.AsyncClient( |
| headers={"User-Agent":"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36"}, |
| limits=httpx.Limits(max_connections=TRANS_CONCURRENCY*3, max_keepalive_connections=TRANS_CONCURRENCY*2), |
| timeout=httpx.Timeout(15.0, connect=5.0), |
| http2=False, |
| ) as client: |
| async def worker(b): |
| async with sem: |
| if is_cancelled(jid): raise InterruptedError("cancelled") |
| b["text"] = await translate_one(client, b["text"], target_lang) |
| async with lock: |
| done[0] += 1 |
| d = done[0] |
| pct = d / total * 100 |
| update(jid, step_progress=round(pct, 2), |
| message=f"Traduzione {d}/{total} ({pct:.1f}%)") |
| await asyncio.gather(*(worker(b) for b in blocks)) |
| log.info(f"[{jid[:8]}] translation done ({total} blocks)") |
| return build_srt(blocks) |
|
|
|
|
| |
| |
| |
| def get_voice(lang, gender): |
| key = (lang.lower(), gender.capitalize()) |
| if key in VOICES: return VOICES[key] |
| for (l, _), v in VOICES.items(): |
| if l == lang.lower(): return v |
| return "en-US-AriaNeural" |
|
|
| def get_duration(path): |
| try: |
| r = subprocess.run([FFPROBE, "-v", "error", "-show_entries", |
| "format=duration", "-of", |
| "default=noprint_wrappers=1:nokey=1", path], |
| capture_output=True, text=True, timeout=10) |
| return float(r.stdout.strip() or 0) |
| except: return 0.0 |
|
|
| def gen_silence(dur, out): |
| if dur <= 0: dur = 0.01 |
| subprocess.run([FFMPEG, "-y", "-f", "lavfi", |
| "-i", "anullsrc=r=24000:cl=mono", "-t", f"{dur:.4f}", |
| "-c:a", "libmp3lame", "-b:a", "48k", "-ar", "24000", "-ac", "1", out], |
| capture_output=True, check=True) |
|
|
| def speed_fit(inp, out, target): |
| actual = get_duration(inp) |
| if actual <= 0: |
| shutil.copy2(inp, out); return |
| ratio = actual / target |
| if ratio < 0.5: ratio = 0.5 |
| if ratio > 100: ratio = 100 |
| filters = [] |
| r = ratio |
| while r > 2.0: filters.append("atempo=2.0"); r /= 2.0 |
| while r < 0.5: filters.append("atempo=0.5"); r /= 0.5 |
| filters.append(f"atempo={r:.6f}") |
| res = subprocess.run([FFMPEG, "-y", "-i", inp, "-filter:a", ",".join(filters), |
| "-c:a", "libmp3lame", "-b:a", "64k", "-ar", "24000", "-ac", "1", |
| "-threads", "1", out], capture_output=True) |
| if res.returncode != 0: |
| shutil.copy2(inp, out) |
|
|
| _TTS_CACHE_DIR = os.path.join(TMP, "dubflow_tts_cache") |
| os.makedirs(_TTS_CACHE_DIR, exist_ok=True) |
|
|
|
|
| def _tts_cache_key(text, voice, rate, pitch, volume): |
| """Genera chiave cache stabile per il TTS.""" |
| key = f"{voice}|{rate}|{pitch}|{volume}|{text}" |
| return hashlib.md5(key.encode()).hexdigest() |
|
|
|
|
| |
| _GTTS_CLASS = None |
|
|
| def _get_gtts(): |
| global _GTTS_CLASS |
| if _GTTS_CLASS is None: |
| try: |
| from gtts import gTTS as _g |
| _GTTS_CLASS = _g |
| except ImportError: |
| import subprocess as sp |
| import sys as _sys |
| sp.check_call([_sys.executable, "-m", "pip", "install", "-q", "gTTS"]) |
| from gtts import gTTS as _g |
| _GTTS_CLASS = _g |
| return _GTTS_CLASS |
|
|
|
|
| _GTTS_LANG_MAP = {"nb": "no", "zh": "zh-cn"} |
|
|
|
|
| async def _gtts_fallback(text, lang_code, out): |
| """Fallback gTTS ottimizzato: import cached + hard link cache.""" |
| loop = asyncio.get_event_loop() |
| gTTS = _get_gtts() |
| lang = _GTTS_LANG_MAP.get(lang_code, lang_code) |
| |
| def _sync_gtts(): |
| try: |
| tts = gTTS(text=text, lang=lang, slow=False, tld="com") |
| tts.save(out) |
| except Exception: |
| tts = gTTS(text=text, lang="en", slow=False) |
| tts.save(out) |
| |
| await loop.run_in_executor(THREAD_POOL, _sync_gtts) |
| |
| try: |
| if os.stat(out).st_size < 100: |
| raise RuntimeError("gTTS empty file") |
| except FileNotFoundError: |
| raise RuntimeError("gTTS no file produced") |
|
|
|
|
| |
| _EDGE_BLOCKED = False |
|
|
|
|
| async def tts_block(text, voice, out, rate, pitch, volume): |
| """TTS ottimizzato: cache, edge-tts con skip rapido, gTTS fallback.""" |
| global _EDGE_BLOCKED |
| |
| |
| cache_key = _tts_cache_key(text, voice, rate, pitch, volume) |
| cache_path = os.path.join(_TTS_CACHE_DIR, f"{cache_key}.mp3") |
| try: |
| st = os.stat(cache_path) |
| if st.st_size > 100: |
| |
| try: |
| os.link(cache_path, out) |
| except (OSError, AttributeError): |
| shutil.copy2(cache_path, out) |
| return |
| except (FileNotFoundError, OSError): |
| pass |
| |
| lang_code = voice.split("-")[0].lower() if "-" in voice else "en" |
| |
| |
| if not _EDGE_BLOCKED: |
| try: |
| comm = edge_tts.Communicate( |
| text=text, voice=voice, |
| rate=rate, pitch=pitch, volume=volume, |
| ) |
| data = bytearray() |
| async for c in comm.stream(): |
| if c["type"] == "audio": |
| data.extend(c["data"]) |
| if data: |
| async with aiofiles.open(out, "wb") as f: |
| await f.write(bytes(data)) |
| |
| try: |
| if not os.path.exists(cache_path): |
| os.link(out, cache_path) |
| except (OSError, AttributeError): |
| try: shutil.copy2(out, cache_path) |
| except: pass |
| return |
| except Exception as e: |
| if "403" in str(e) or "Invalid response" in str(e): |
| _EDGE_BLOCKED = True |
| log.warning("edge-tts permanently blocked on this IP, using gTTS only") |
| |
| |
| await _gtts_fallback(text, lang_code, out) |
| |
| try: |
| if not os.path.exists(cache_path): |
| os.link(out, cache_path) |
| except (OSError, AttributeError): |
| try: shutil.copy2(out, cache_path) |
| except: pass |
|
|
|
|
| async def step3_tts(srt, lang, gender, out_mp3, jid, rate, pitch, volume): |
| update(jid, step=3, stage="tts", step_progress=0, message="Avvio TTS…") |
| blocks = parse_srt(srt) |
| if not blocks: raise Exception("no blocks") |
| voice = get_voice(lang, gender) |
| log.info(f"[{jid[:8]}] TTS start: {len(blocks)} blocks, voice={voice}") |
| tmp = os.path.join(TMP, f"dub_tts_{uuid.uuid4().hex}") |
| os.makedirs(tmp, exist_ok=True) |
| sem = asyncio.Semaphore(TTS_CONCURRENCY) |
| total = len(blocks) |
| done = [0] |
| lock = asyncio.Lock() |
| loop = asyncio.get_event_loop() |
| results = [None] * total |
| t_start = time.time() |
|
|
| async def process(i, b): |
| async with sem: |
| if is_cancelled(jid): raise InterruptedError("cancelled") |
| text = clean_text(b["text"]) |
| slot = b["end_sec"] - b["start_sec"] |
| if not text or slot <= 0: |
| async with lock: |
| done[0] += 1 |
| d = done[0] |
| pct = d / total * 85 |
| update(jid, step_progress=round(pct, 2), |
| message=f"TTS {d}/{total} (skip vuoto)") |
| return |
| raw = os.path.join(tmp, f"b{i:04d}_raw.mp3") |
| fit = os.path.join(tmp, f"b{i:04d}_fit.mp3") |
| try: |
| await tts_block(text, voice, raw, rate, pitch, volume) |
| except Exception as e: |
| log.warning(f"[{jid[:8]}] TTS block {i}: {e}") |
| async with lock: |
| done[0] += 1 |
| d = done[0] |
| pct = d / total * 85 |
| update(jid, step_progress=round(pct, 2), |
| message=f"TTS {d}/{total} (errore blocco)") |
| return |
| actual = await loop.run_in_executor(THREAD_POOL, get_duration, raw) |
| if actual > slot * 1.05: |
| await loop.run_in_executor(THREAD_POOL, speed_fit, raw, fit, slot) |
| else: |
| await loop.run_in_executor(THREAD_POOL, shutil.copy2, raw, fit) |
| results[i] = (fit, b["start_sec"], slot) |
| |
| async with lock: |
| done[0] += 1 |
| d = done[0] |
| pct = d / total * 85 |
| elapsed = time.time() - t_start |
| rate_per_sec = d / elapsed if elapsed > 0 else 0 |
| eta = (total - d) / rate_per_sec if rate_per_sec > 0 else 0 |
| update(jid, step_progress=round(pct, 2), |
| message=f"TTS {d}/{total} · {rate_per_sec:.1f} blk/s · ETA {eta:.0f}s") |
| |
| |
| if d % 5 == 0 or d == total: |
| log.info(f"[{jid[:8]}] TTS {d}/{total} ({pct:.1f}%) · {rate_per_sec:.1f} blk/s · ETA {eta:.0f}s") |
|
|
| try: |
| await asyncio.gather(*(process(i, b) for i, b in enumerate(blocks))) |
| segs = [r for r in results if r] |
| if not segs: raise Exception("no segments") |
| update(jid, step_progress=87, message=f"Assemblaggio {len(segs)} segmenti…") |
| log.info(f"[{jid[:8]}] TTS done in {time.time()-t_start:.1f}s, assembling {len(segs)} segments") |
| await loop.run_in_executor(THREAD_POOL, _assemble, segs, blocks, tmp, out_mp3) |
| update(jid, step_progress=100, message="Audio sincronizzato pronto") |
| log.info(f"[{jid[:8]}] TTS+assembly total: {time.time()-t_start:.1f}s") |
| finally: |
| shutil.rmtree(tmp, ignore_errors=True) |
|
|
| def _assemble(segs, blocks, tmp, out_mp3): |
| last_end = max(b["end_sec"] for b in blocks) |
| segs_s = sorted(segs, key=lambda x: x[1]) |
| padded = os.path.join(tmp, "padded"); os.makedirs(padded, exist_ok=True) |
| concat = [] |
| current = 0.0 |
| for idx, (seg, start, slot) in enumerate(segs_s): |
| gap = start - current |
| if gap > 0.01: |
| sp = os.path.join(padded, f"sil_{idx:04d}.mp3") |
| gen_silence(gap, sp); concat.append(sp); current += gap |
| concat.append(seg) |
| sd = get_duration(seg) or slot |
| current += sd |
| rem = last_end - current |
| if rem > 0.1: |
| tp = os.path.join(padded, "sil_trail.mp3") |
| gen_silence(rem, tp); concat.append(tp) |
| listf = os.path.join(tmp, "concat.txt") |
| with open(listf, "w") as f: |
| for p in concat: |
| safe = p.replace("\\", "/").replace("'", "'\\''") |
| f.write(f"file '{safe}'\n") |
| res = subprocess.run([FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", listf, |
| "-c:a", "libmp3lame", "-b:a", "128k", "-ar", "24000", "-ac", "1", |
| "-threads", str(FFMPEG_THREADS), out_mp3], capture_output=True) |
| if res.returncode != 0: |
| raise RuntimeError(f"concat: {res.stderr.decode()[:300]}") |
|
|
|
|
| |
| |
| |
| async def step4_dub(video, audio, out, mode, dub_vol, orig_vol, fade, jid): |
| update(jid, step=4, stage="dub", step_progress=5, message=f"Dub ({mode})") |
| duration = await get_video_duration_async(video) |
| log.info(f"[{jid[:8]}] dub duration={duration:.1f}s mode={mode}") |
| |
| base = [FFMPEG, "-y", "-i", video, "-i", audio, "-c:v", "copy", |
| "-threads", str(FFMPEG_THREADS), "-movflags", "+faststart", |
| "-progress", "pipe:1", "-nostats"] |
| if mode == "replace": |
| cmd = base + ["-map", "0:v:0", "-map", "1:a:0", |
| "-c:a", "aac", "-b:a", "192k", "-shortest", out] |
| elif mode == "mix": |
| fc = (f"[0:a]volume={orig_vol}[o];[1:a]volume={dub_vol}[d];" |
| f"[o][d]amix=inputs=2:duration=shortest:dropout_transition=2[a]") |
| cmd = base + ["-filter_complex", fc, "-map", "0:v:0", "-map", "[a]", |
| "-c:a", "aac", "-b:a", "192k", out] |
| else: |
| mn = min(duration, get_duration(audio)) or 300 |
| fo = max(0, mn - fade) |
| fc = (f"[0:a]volume={orig_vol}[o];" |
| f"[1:a]volume={dub_vol},afade=t=in:st=0:d={fade}," |
| f"afade=t=out:st={fo}:d={fade}[d];" |
| f"[o][d]amix=inputs=2:duration=shortest:dropout_transition=2," |
| f"loudnorm=I=-16:TP=-1.5:LRA=11[a]") |
| cmd = base + ["-filter_complex", fc, "-map", "0:v:0", "-map", "[a]", |
| "-c:a", "aac", "-b:a", "192k", out] |
|
|
| await run_ffmpeg_with_progress(cmd, duration, jid, 10, 85, "FFmpeg ") |
| update(jid, step_progress=100, message="Dub completato") |
| log.info(f"[{jid[:8]}] dub completed") |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| async def run_pipeline(jid, base_url): |
| j = JOBS[jid] |
| log.info(f"[{jid[:8]}] pipeline waiting for video…") |
| |
| wait_start = time.time() |
| while not j["video_ready"]: |
| if j.get("error") or j["cancel"]: |
| log.info(f"[{jid[:8]}] pipeline aborted before video") |
| return |
| if time.time() - wait_start > 1200: |
| update(jid, error="Timeout attesa video", done=True) |
| return |
| await asyncio.sleep(0.01) |
| |
| log.info(f"[{jid[:8]}] video ready, starting pipeline") |
| |
| update(jid, step=0, step_progress=100, message="Video ricevuto, avvio pipeline…") |
| await asyncio.sleep(0.1) |
| video_path = j["video_path"] |
| p = j["params"] |
| target_lang = p["lang"] |
| |
| |
| original = j.get("original_name", "video") |
| safe_original = re.sub(r'[^\w\-_.]', '_', original)[:60] or "video" |
| |
| safe_original = re.sub(r'_+', '_', safe_original).strip('_') or "video" |
| uid_short = uuid.uuid4().hex[:6] |
| |
| out_mp4_name = f"{safe_original}_doppiato_{target_lang}_{uid_short}.mp4" |
| srt_orig_name = f"{safe_original}_sottotitoli_{uid_short}.srt" |
| srt_trans_name = f"{safe_original}_traduzione_{target_lang}_{uid_short}.srt" |
| |
| out_path = os.path.join(PUBLIC, out_mp4_name) |
| srt_path = os.path.join(PUBLIC, srt_orig_name) |
| tsrt_path = os.path.join(PUBLIC, srt_trans_name) |
| tts_path = os.path.join(TMP, f"dub_tts_{uid_short}.mp3") |
|
|
| t0 = time.time() |
| try: |
| srt = await step1_extract_srt(video_path, jid) |
| async with aiofiles.open(srt_path, "w", encoding="utf-8") as f: |
| await f.write(srt) |
|
|
| tsrt = await step2_translate(srt, target_lang, jid) |
| async with aiofiles.open(tsrt_path, "w", encoding="utf-8") as f: |
| await f.write(tsrt) |
|
|
| await step3_tts(tsrt, target_lang, p["gender"], tts_path, jid, |
| p["rate"], p["pitch"], p["volume"]) |
|
|
| await step4_dub(video_path, tts_path, out_path, |
| p["mode"], p["dub_volume"], p["orig_volume"], p["fade"], jid) |
| |
| elapsed = time.time() - t0 |
| update(jid, stage="done", done=True, step=4, step_progress=100, |
| message=f"Completato in {elapsed:.2f}s", |
| result={ |
| "video_url": f"{base_url}/file/{out_mp4_name}", |
| "video_download": f"{base_url}/file/{out_mp4_name}?dl=1", |
| "video_filename": out_mp4_name, |
| "srt_original": f"{base_url}/file/{srt_orig_name}", |
| "srt_original_filename": srt_orig_name, |
| "srt_translated": f"{base_url}/file/{srt_trans_name}", |
| "srt_filename": srt_trans_name, |
| "language": target_lang, "gender": p["gender"], |
| "elapsed_sec": round(elapsed, 2), |
| }) |
| JOBS[jid]["progress"] = 100.0 |
| log.info(f"[{jid[:8]}] ✅ done in {elapsed:.2f}s → {out_mp4_name}") |
| except InterruptedError: |
| log.info(f"[{jid[:8]}] cancelled") |
| update(jid, error="Cancellato", done=True) |
| except Exception as e: |
| log.exception(f"[{jid[:8]}] ❌ failed: {e}") |
| update(jid, error=str(e)[:300], done=True) |
| finally: |
| cleanup_paths([video_path, tts_path]) |
|
|
|
|
|
|
| @app.get("/progress/{jid}", tags=["Progress"]) |
| async def progress(jid: str): |
| """Server-Sent Events stream con stato job in real-time.""" |
| async def gen(): |
| last_send = None |
| last_time = time.time() |
| while True: |
| j = JOBS.get(jid) |
| if not j: |
| yield f"data: {json.dumps({'error':'job not found'})}\n\n" |
| return |
| payload = {k: j[k] for k in |
| ("progress","step","step_progress","message","stage","done","error","result")} |
| s = json.dumps(payload) |
| now = time.time() |
| |
| if s != last_send: |
| yield f"data: {s}\n\n" |
| last_send = s |
| last_time = now |
| elif (now - last_time) > 1.5: |
| yield f"data: {s}\n\n" |
| last_time = now |
| if j["done"] or j["error"]: |
| return |
| await asyncio.sleep(0.02) |
| return StreamingResponse(gen(), media_type="text/event-stream", |
| headers={"Cache-Control":"no-cache", |
| "X-Accel-Buffering":"no", |
| "Connection":"keep-alive"}) |
|
|
|
|
| @app.get("/status/{jid}", tags=["Progress"]) |
| async def status(jid: str): |
| """Polling endpoint (per chi non usa SSE).""" |
| j = JOBS.get(jid) |
| if not j: raise HTTPException(404, "job not found") |
| return {k: j[k] for k in |
| ("progress","step","step_progress","message","stage","done","error","result","created")} |
|
|
|
|
| @app.post("/cancel/{jid}", tags=["Jobs"]) |
| async def cancel(jid: str): |
| if jid in JOBS: |
| JOBS[jid]["cancel"] = True |
| log.info(f"[{jid[:8]}] cancel requested") |
| return {"ok": True} |
| raise HTTPException(404, "not found") |
|
|
|
|
| |
| |
| |
| def make_params(lang, gender, mode, dub_volume, orig_volume, fade, rate, pitch, volume): |
| return { |
| "lang": lang, "gender": gender, "mode": mode, |
| "dub_volume": float(dub_volume), "orig_volume": float(orig_volume), "fade": float(fade), |
| "rate": rate, "pitch": pitch, "volume": volume, |
| } |
|
|
|
|
| @app.post("/job/create", tags=["Jobs"]) |
| async def job_create(request: Request, |
| lang: str = "it", gender: str = "Female", mode: str = "replace", |
| dub_volume: float = 0.85, orig_volume: float = 0.15, fade: float = 1.0, |
| rate: str = "+0%", pitch: str = "+0Hz", volume: str = "+0%"): |
| """Crea job vuoto. Poi chiama /job/{jid}/upload o /job/{jid}/url.""" |
| jid = new_job() |
| JOBS[jid]["params"] = make_params(lang, gender, mode, dub_volume, orig_volume, fade, rate, pitch, volume) |
| update(jid, stage="upload", step=0, step_progress=0, message="In attesa video…") |
| base = str(request.base_url).rstrip("/") |
| asyncio.create_task(run_pipeline(jid, base)) |
| return {"job_id": jid, "status_url": f"{base}/status/{jid}", "progress_url": f"{base}/progress/{jid}"} |
| @app.post("/job/{jid}/upload", tags=["Jobs"]) |
| async def job_upload(jid: str, video: UploadFile = File(...)): |
| j = JOBS.get(jid) |
| if not j: raise HTTPException(404, "job not found") |
| if j["video_ready"]: raise HTTPException(400, "video già caricato") |
| |
| |
| original_name = os.path.basename(video.filename or "video.mp4") |
| name_stem = os.path.splitext(original_name)[0] |
| |
| name_stem = re.sub(r'[^\w\-_.]', '_', name_stem)[:80] or "video" |
| j["original_name"] = name_stem |
| log.info(f"[{jid[:8]}] original filename: '{original_name}' → stem: '{name_stem}'") |
| |
| uid = uuid.uuid4().hex |
| vpath = os.path.join(TMP, f"dub_in_{uid}.mp4") |
| size = 0 |
| |
| try: |
| async with aiofiles.open(vpath, "wb") as f: |
| last_t = 0 |
| while True: |
| c = await video.read(CHUNK_SIZE * 4) |
| if not c: break |
| await f.write(c) |
| size += len(c) |
| if size > MAX_UPLOAD_MB * 1024 * 1024: |
| raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB") |
| now = time.time() |
| if now - last_t > 0.05: |
| update(jid, step=0, step_progress=min(95, size/(MAX_UPLOAD_MB*1024*1024)*95), |
| message=f"Ricezione server {size/1048576:.2f} MB") |
| last_t = now |
| |
| if size < MIN_FILE_BYTES: |
| raise HTTPException(400, "File troppo piccolo") |
| |
| update(jid, step_progress=97, message="Validazione…") |
| await validate_video(vpath) |
| log.info(f"[{jid[:8]}] upload OK: {size/1048576:.2f} MB") |
| update(jid, step=0, step_progress=100, message=f"Video pronto ({size/1048576:.2f} MB)", |
| video_path=vpath, video_ready=True) |
| return {"ok": True, "size_mb": round(size/1048576, 2), "name": name_stem} |
| except HTTPException as e: |
| cleanup_paths([vpath]) |
| update(jid, error=str(e.detail), done=True) |
| raise |
| except Exception as e: |
| log.exception(f"[{jid[:8]}] upload failed") |
| cleanup_paths([vpath]) |
| update(jid, error=str(e), done=True) |
| raise HTTPException(500, str(e)) |
| @app.post("/job/{jid}/url", tags=["Jobs"]) |
| async def job_url(jid: str, video: str = Query(...)): |
| j = JOBS.get(jid) |
| if not j: raise HTTPException(404, "job not found") |
| if j["video_ready"]: raise HTTPException(400, "video già caricato") |
| |
| video = video.strip() |
| if video.startswith("blob:"): |
| update(jid, error="blob: non supportato", done=True) |
| raise HTTPException(400, "blob: non scaricabile. Usa Upload.") |
| if not re.match(r'^https?://', video, re.I): |
| update(jid, error="URL non valido", done=True) |
| raise HTTPException(400, "URL deve iniziare con http/https") |
| |
| from urllib.parse import urlparse, unquote |
| parsed = urlparse(video) |
| url_path = unquote(parsed.path) |
| original_name = os.path.basename(url_path) or "video.mp4" |
| |
| |
| if "." not in original_name or len(original_name) < 5: |
| name_stem = "video" |
| else: |
| name_stem = os.path.splitext(original_name)[0] |
| name_stem = re.sub(r'[^\w\-_.]', '_', name_stem)[:80] or "video" |
| j["original_name"] = name_stem |
| log.info(f"[{jid[:8]}] URL: {video}") |
| log.info(f"[{jid[:8]}] URL filename: '{original_name}' → stem: '{name_stem}'") |
| |
| uid = uuid.uuid4().hex |
| vpath = os.path.join(TMP, f"dub_in_{uid}.mp4") |
| |
| |
| browser_headers = { |
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " |
| "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", |
| "Accept": "video/mp4,video/*,*/*;q=0.8", |
| "Accept-Language": "en-US,en;q=0.9,it;q=0.8", |
| "Accept-Encoding": "identity", |
| "Connection": "keep-alive", |
| "Sec-Fetch-Dest": "video", |
| "Sec-Fetch-Mode": "no-cors", |
| "Sec-Fetch-Site": "cross-site", |
| } |
| |
| try: |
| update(jid, step=0, step_progress=2, message="Connessione…") |
| |
| |
| last_error = None |
| for attempt in range(3): |
| try: |
| async with httpx.AsyncClient( |
| timeout=httpx.Timeout(60.0, connect=30.0, read=600.0), |
| follow_redirects=True, |
| headers=browser_headers, |
| ) as c: |
| |
| try: |
| head_resp = await c.head(video, timeout=15.0) |
| if "content-disposition" in head_resp.headers: |
| cd = head_resp.headers["content-disposition"] |
| m = re.search(r'filename[*]?=["\']?([^"\';]+)', cd) |
| if m: |
| real_name = m.group(1).strip().strip('"').strip("'") |
| if real_name.startswith("UTF-8''"): |
| real_name = unquote(real_name[7:]) |
| stem = os.path.splitext(os.path.basename(real_name))[0] |
| stem = re.sub(r'[^\w\-_.]', '_', stem)[:80] |
| if stem and stem != "video": |
| j["original_name"] = stem |
| log.info(f"[{jid[:8]}] real filename from header: '{stem}'") |
| except Exception as he: |
| log.debug(f"[{jid[:8]}] HEAD failed (non critico): {he}") |
| |
| async with c.stream("GET", video) as r: |
| if r.status_code == 404: |
| raise Exception(f"File non trovato (404). URL: {video[:80]}") |
| if r.status_code == 403: |
| raise Exception(f"Accesso negato (403)") |
| if r.status_code not in (200, 206): |
| raise Exception(f"HTTP {r.status_code}") |
| |
| total = int(r.headers.get("content-length", 0)) |
| if total and total > MAX_UPLOAD_MB * 1024 * 1024: |
| raise HTTPException(413, f"File troppo grande: {total/1048576:.1f} MB > {MAX_UPLOAD_MB} MB") |
| |
| size = 0 |
| last_t = 0 |
| t_start = time.time() |
| async with aiofiles.open(vpath, "wb") as f: |
| async for ch in r.aiter_bytes(CHUNK_SIZE * 4): |
| await f.write(ch) |
| size += len(ch) |
| if size > MAX_UPLOAD_MB * 1024 * 1024: |
| raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB") |
| now = time.time() |
| if now - last_t > 0.05: |
| elapsed = now - t_start |
| speed = size / elapsed / 1048576 if elapsed > 0 else 0 |
| pct = (size/total*98) if total else min(98, size/(1024*1024)) |
| if total: |
| eta = (total - size) / (size/elapsed) if size > 0 else 0 |
| update(jid, step_progress=round(pct, 2), |
| message=f"Download {size/1048576:.2f}/{total/1048576:.2f} MB · {speed:.2f} MB/s · ETA {eta:.1f}s") |
| else: |
| update(jid, step_progress=round(pct, 2), |
| message=f"Download {size/1048576:.2f} MB · {speed:.2f} MB/s") |
| last_t = now |
| |
| |
| break |
| |
| except HTTPException: |
| raise |
| except Exception as e: |
| last_error = e |
| log.warning(f"[{jid[:8]}] download attempt {attempt+1}/3 failed: {e}") |
| if attempt < 2: |
| update(jid, message=f"Retry {attempt+2}/3 dopo errore…") |
| await asyncio.sleep(2.0) |
| |
| try: os.remove(vpath) |
| except: pass |
| size = 0 |
| else: |
| raise Exception(f"Download fallito dopo 3 tentativi: {last_error}") |
| |
| if size < MIN_FILE_BYTES: |
| raise HTTPException(400, f"File troppo piccolo ({size} bytes)") |
| |
| update(jid, step_progress=99, message="Validazione…") |
| await validate_video(vpath) |
| log.info(f"[{jid[:8]}] download OK: {size/1048576:.2f} MB") |
| update(jid, step=0, step_progress=100, message=f"Video pronto ({size/1048576:.2f} MB)", |
| video_path=vpath, video_ready=True) |
| return {"ok": True, "size_mb": round(size/1048576, 2), "name": j["original_name"]} |
| except HTTPException as e: |
| cleanup_paths([vpath]) |
| update(jid, error=str(e.detail), done=True) |
| raise |
| except Exception as e: |
| log.exception(f"[{jid[:8]}] download failed") |
| cleanup_paths([vpath]) |
| update(jid, error=str(e)[:200], done=True) |
| raise HTTPException(500, str(e)[:200]) |
|
|
|
|
| |
| |
| |
| @app.get("/api/dub", tags=["REST API"]) |
| async def api_dub_get(request: Request, |
| video: str = Query(..., description="URL diretto MP4 (http/https)"), |
| lang: str = Query("it", description="Codice lingua target (es. it, en, es)"), |
| gender: str = Query("Female", description="Female | Male"), |
| mode: str = Query("replace", description="replace | mix | advanced"), |
| dub_volume: float = 0.85, |
| orig_volume: float = 0.15, |
| fade: float = 1.0, |
| rate: str = "+0%", |
| pitch: str = "+0Hz", |
| volume: str = "+0%", |
| wait: bool = Query(False, description="True = blocca finché completato (max 30min)"), |
| timeout: int = Query(1800, description="Timeout in secondi se wait=true")): |
| """ |
| REST GET — Avvia doppiaggio da URL. |
| |
| **Esempio sincrono (blocca):** |
| ``` |
| GET /api/dub?video=https://example.com/video.mp4&lang=it&wait=true |
| ``` |
| |
| **Esempio asincrono (subito job_id, polla status):** |
| ``` |
| GET /api/dub?video=https://example.com/video.mp4&lang=it |
| → {"job_id": "abc...", "status_url": "..."} |
| GET /status/abc... |
| ``` |
| """ |
| |
| video = video.strip() |
| if video.startswith("blob:"): |
| raise HTTPException(400, "blob: non supportato") |
| if not re.match(r'^https?://', video, re.I): |
| raise HTTPException(400, "URL deve iniziare con http/https") |
| |
| base = str(request.base_url).rstrip("/") |
| |
| |
| jid = new_job() |
| JOBS[jid]["params"] = make_params(lang, gender, mode, dub_volume, orig_volume, fade, rate, pitch, volume) |
| update(jid, stage="upload", step=0, step_progress=0, message="Avvio…") |
| asyncio.create_task(run_pipeline(jid, base)) |
| |
| |
| async def do_download(): |
| try: |
| uid = uuid.uuid4().hex |
| vpath = os.path.join(TMP, f"dub_in_{uid}.mp4") |
| async with httpx.AsyncClient(timeout=600, follow_redirects=True, |
| headers={"User-Agent": "Mozilla/5.0"}) as c: |
| async with c.stream("GET", video) as r: |
| if r.status_code not in (200, 206): |
| raise Exception(f"HTTP {r.status_code}") |
| total = int(r.headers.get("content-length", 0)) |
| if total and total > MAX_UPLOAD_MB * 1024 * 1024: |
| raise Exception(f"Max {MAX_UPLOAD_MB} MB") |
| size = 0 |
| last_t = 0 |
| async with aiofiles.open(vpath, "wb") as f: |
| async for ch in r.aiter_bytes(CHUNK_SIZE * 4): |
| await f.write(ch); size += len(ch) |
| if size > MAX_UPLOAD_MB * 1024 * 1024: |
| raise Exception(f"Max {MAX_UPLOAD_MB} MB") |
| now = time.time() |
| if now - last_t > 0.05: |
| pct = (size/total*95) if total else min(95, size/(1024*1024)) |
| update(jid, step_progress=round(pct, 2), |
| message=f"Download {size/1048576:.1f} MB") |
| last_t = now |
| if size < MIN_FILE_BYTES: |
| raise Exception("File troppo piccolo") |
| await validate_video(vpath) |
| update(jid, step=0, step_progress=100, message="Video pronto", |
| video_path=vpath, video_ready=True) |
| except Exception as e: |
| log.error(f"[{jid[:8]}] api download fail: {e}") |
| update(jid, error=f"Download fallito: {e}", done=True) |
| |
| if not wait: |
| asyncio.create_task(do_download()) |
| return { |
| "job_id": jid, |
| "status_url": f"{base}/status/{jid}", |
| "progress_url": f"{base}/progress/{jid}", |
| "cancel_url": f"{base}/cancel/{jid}", |
| } |
| |
| |
| await do_download() |
| deadline = time.time() + timeout |
| while time.time() < deadline: |
| j = JOBS.get(jid) |
| if not j: |
| raise HTTPException(500, "Job perso") |
| if j["done"]: |
| if j.get("error"): |
| raise HTTPException(500, j["error"]) |
| return { |
| "job_id": jid, |
| "status": "completed", |
| **(j["result"] or {}), |
| } |
| await asyncio.sleep(1.0) |
| raise HTTPException(504, f"Timeout dopo {timeout}s. Job ancora attivo: {jid}") |
|
|
|
|
| @app.post("/api/dub", tags=["REST API"]) |
| async def api_dub_post(request: Request, |
| video: UploadFile = File(...), |
| lang: str = "it", gender: str = "Female", mode: str = "replace", |
| dub_volume: float = 0.85, orig_volume: float = 0.15, fade: float = 1.0, |
| rate: str = "+0%", pitch: str = "+0Hz", volume: str = "+0%", |
| wait: bool = False, timeout: int = 1800): |
| """ |
| REST POST — Doppiaggio da file uploadato (multipart/form-data). |
| |
| **curl esempio:** |
| ``` |
| curl -F "video=@video.mp4" "https://host/api/dub?lang=it&wait=true" |
| ``` |
| """ |
| base = str(request.base_url).rstrip("/") |
| jid = new_job() |
| JOBS[jid]["params"] = make_params(lang, gender, mode, dub_volume, orig_volume, fade, rate, pitch, volume) |
| update(jid, stage="upload", step=0, step_progress=0, message="Ricezione…") |
| asyncio.create_task(run_pipeline(jid, base)) |
| |
| uid = uuid.uuid4().hex |
| vpath = os.path.join(TMP, f"dub_in_{uid}.mp4") |
| size = 0 |
| try: |
| async with aiofiles.open(vpath, "wb") as f: |
| last_t = 0 |
| while True: |
| c = await video.read(CHUNK_SIZE * 4) |
| if not c: break |
| await f.write(c); size += len(c) |
| if size > MAX_UPLOAD_MB * 1024 * 1024: |
| raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB") |
| now = time.time() |
| if now - last_t > 0.05: |
| update(jid, step_progress=min(95, size/(MAX_UPLOAD_MB*1024*1024)*95), |
| message=f"Upload {size/1048576:.1f} MB") |
| last_t = now |
| if size < MIN_FILE_BYTES: |
| raise HTTPException(400, "File troppo piccolo") |
| await validate_video(vpath) |
| update(jid, step=0, step_progress=100, message="Video pronto", |
| video_path=vpath, video_ready=True) |
| except HTTPException as e: |
| cleanup_paths([vpath]) |
| update(jid, error=str(e.detail), done=True) |
| raise |
| except Exception as e: |
| cleanup_paths([vpath]) |
| update(jid, error=str(e), done=True) |
| raise HTTPException(500, str(e)) |
| |
| if not wait: |
| return { |
| "job_id": jid, |
| "size_mb": round(size/1048576, 2), |
| "status_url": f"{base}/status/{jid}", |
| "progress_url": f"{base}/progress/{jid}", |
| } |
| |
| deadline = time.time() + timeout |
| while time.time() < deadline: |
| j = JOBS.get(jid) |
| if not j: raise HTTPException(500, "Job perso") |
| if j["done"]: |
| if j.get("error"): |
| raise HTTPException(500, j["error"]) |
| return {"job_id": jid, "status": "completed", **(j["result"] or {})} |
| await asyncio.sleep(1.0) |
| raise HTTPException(504, f"Timeout dopo {timeout}s") |
|
|
|
|
| |
| |
| |
| @app.get("/file/{name}", tags=["Files"]) |
| async def serve(name: str, dl: int = 0): |
| path = os.path.join(PUBLIC, name) |
| if not os.path.exists(path): raise HTTPException(404) |
| mt = "video/mp4" if name.endswith(".mp4") else "text/plain; charset=utf-8" |
| headers = {"Cache-Control": "public, max-age=3600"} |
| if dl: |
| return FileResponse(path, media_type=mt, filename=name, headers=headers) |
| return FileResponse(path, media_type=mt, headers=headers) |
|
|
|
|
| @app.get("/languages", tags=["Meta"]) |
| async def languages(): |
| return {"languages": [{"code":k, "name":v} for k,v in LANGUAGES.items()]} |
|
|
|
|
| @app.get("/voices", tags=["Meta"]) |
| async def voices(): |
| return {"voices": [{"lang":l, "gender":g, "voice":v} for (l,g),v in VOICES.items()]} |
|
|
|
|
| @app.get("/health", tags=["Meta"]) |
| async def health(): |
| return {"status":"ok", "version": "2.6.0", |
| "jobs_active": sum(1 for j in JOBS.values() if not j["done"]), |
| "jobs_total": len(JOBS), |
| "max_upload_mb": MAX_UPLOAD_MB} |
|
|
|
|
| @app.get("/jobs", tags=["Jobs"]) |
| async def list_jobs(active_only: bool = False): |
| """Lista jobs con stato corrente.""" |
| out = [] |
| for jid, j in JOBS.items(): |
| if active_only and j["done"]: continue |
| out.append({ |
| "id": jid, "stage": j["stage"], "progress": j["progress"], |
| "step": j["step"], "done": j["done"], "error": j["error"], |
| "created": j["created"], |
| }) |
| return {"jobs": out, "count": len(out)} |
|
|
|
|
| |
| |
| |
| @app.get("/", response_class=HTMLResponse, tags=["UI"]) |
| async def home(): |
| opts = "".join(f'<option value="{k}">{k} — {v}</option>' for k, v in LANGUAGES.items()) |
| return UI.replace("__LANG_OPTIONS__", opts).replace("__MAX_MB__", str(MAX_UPLOAD_MB)) |
|
|
|
|
| UI = r"""<!DOCTYPE html> |
| <html lang="it"><head><meta charset="UTF-8"> |
| <title>DubFlow Web</title> |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <style> |
| body{background:radial-gradient(900px 600px at 10% -10%,rgba(139,92,246,.18),transparent 60%),radial-gradient(800px 500px at 100% 0%,rgba(56,189,248,.15),transparent 60%),#07090d;min-height:100vh;font-family:'Inter',system-ui,sans-serif;} |
| .glass{background:rgba(255,255,255,.04);backdrop-filter:blur(18px);border:1px solid rgba(255,255,255,.08);} |
| .drop{border:1.5px dashed rgba(255,255,255,.18);transition:.25s;} |
| .drop.drag{border-color:#a78bfa;background:rgba(167,139,250,.08);} |
| .btn{background:linear-gradient(90deg,#8b5cf6,#6366f1,#3b82f6);background-size:200% 100%;transition:.4s;} |
| .btn-download{display:flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:10px 14px;min-width:0;max-width:100%;} |
| .btn-download span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11.5px;font-family:'JetBrains Mono',monospace;} |
| .btn-download::before{content:'⬇';flex-shrink:0;font-size:14px;} |
| .dl-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;} |
| .dl-row > a{min-width:0;} |
| .btn:hover:not(:disabled){background-position:100% 0;box-shadow:0 10px 40px rgba(139,92,246,.35);} |
| .btn:disabled{opacity:.5;cursor:not-allowed;} |
| .tab{transition:.25s;border:1px solid rgba(255,255,255,.08);} |
| .tab.active{background:linear-gradient(90deg,#8b5cf6,#6366f1);border-color:transparent;} |
| .bar-track{height:8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:999px;overflow:hidden;} |
| .bar-fill{height:100%;width:0%;background:linear-gradient(90deg,#a78bfa,#6366f1,#3b82f6,#a78bfa);background-size:300% 100%;animation:flow 3s linear infinite;transition:width 80ms linear;border-radius:999px;box-shadow:0 0 20px rgba(139,92,246,.5);will-change:width;} |
| .bar-fill.step{background:linear-gradient(90deg,#22d3ee,#06b6d4,#0891b2,#22d3ee);background-size:300% 100%;} |
| .bar-fill.upload{background:linear-gradient(90deg,#10b981,#059669,#047857,#10b981);background-size:300% 100%;} |
| @keyframes flow{0%{background-position:0% 0}100%{background-position:300% 0}} |
| .code{font-family:'JetBrains Mono',monospace;font-size:11px;} |
| .label{letter-spacing:.12em;text-transform:uppercase;font-size:11px;color:#94a3b8;} |
| input,select{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);} |
| input:focus,select:focus{border-color:#a78bfa;box-shadow:0 0 0 3px rgba(167,139,250,.15);outline:none;} |
| .step-row{display:flex;align-items:flex-start;gap:10px;padding:12px;border-radius:10px;background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.05);transition:background .3s, border-color .3s;} |
| .step-row.done-row{background:rgba(16,185,129,.04);border-color:rgba(16,185,129,.15);} |
| .step-row.active{background:rgba(139,92,246,.08);border-color:rgba(139,92,246,.3);} |
| .step-num{width:28px;height:28px;border-radius:999px;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:12px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);color:#94a3b8;flex-shrink:0;transition:.3s;margin-top:1px;} |
| .step-num.active{background:linear-gradient(90deg,#8b5cf6,#3b82f6);border-color:transparent;color:white;animation:pulse 2s infinite;} |
| .step-num.done{background:#10b981;border-color:transparent;color:white;} |
| @keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(139,92,246,.5)}50%{box-shadow:0 0 0 8px rgba(139,92,246,0)}} |
| .toast{position:fixed;bottom:20px;right:20px;padding:14px 22px;border-radius:10px;color:white;font-size:13px;animation:slideIn .3s;z-index:100;max-width:420px;box-shadow:0 10px 40px rgba(0,0,0,.4);} |
| .toast.err{background:rgba(239,68,68,.95);} .toast.ok{background:rgba(16,185,129,.95);} .toast.info{background:rgba(59,130,246,.95);} |
| @keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}} |
| .hint{font-size:11px;color:#64748b;margin-top:4px;} |
| .step-msg{font-size:10.5px;color:#94a3b8;margin-top:6px;font-family:'JetBrains Mono',monospace;line-height:1.3;min-height:13px;letter-spacing:.01em;} |
| .step-row.active .step-msg{color:#c4b5fd;} |
| .step-row.done-row .step-msg{color:#6ee7b7;} |
| .speed{font-size:10px;color:#10b981;font-family:'JetBrains Mono',monospace;} |
| .heartbeat{display:inline-block;width:6px;height:6px;border-radius:50%;background:#10b981;margin-left:6px;animation:beat 1s infinite;} |
| @keyframes beat{0%,100%{opacity:.3}50%{opacity:1;box-shadow:0 0 8px #10b981}} |
| </style></head><body class="text-slate-100"> |
| |
| <div class="max-w-5xl mx-auto pt-10 px-6 pb-20"> |
| <header class="mb-8"> |
| <p class="label mb-2">Engine v2.6.0 — Real-time + REST API</p> |
| <h1 class="text-4xl md:text-5xl font-semibold tracking-tight">DubFlow <span class="text-slate-400 font-light">Web</span></h1> |
| <p class="text-slate-400 mt-2 text-sm">Pipeline: upload · estrazione · traduzione · TTS · dub</p> |
| </header> |
| |
| <div class="flex gap-2 mb-5"> |
| <button onclick="tab('file')" id="t-file" class="tab active flex-1 py-2.5 rounded-xl text-sm font-medium">Upload File</button> |
| <button onclick="tab('url')" id="t-url" class="tab flex-1 py-2.5 rounded-xl text-sm font-medium">URL Remoto</button> |
| </div> |
| |
| <div class="grid md:grid-cols-2 gap-5"> |
| <div class="glass rounded-2xl p-6 space-y-4"> |
| |
| <div id="pane-file"> |
| <div id="drop" class="drop rounded-xl p-8 text-center cursor-pointer"> |
| <p class="label mb-1">Video</p> |
| <p class="text-slate-300 text-sm">Trascina o clicca</p> |
| <p id="fname" class="text-xs text-slate-500 mt-2">Nessun file (max __MAX_MB__ MB)</p> |
| <input id="vfile" type="file" accept="video/*" class="hidden"> |
| </div> |
| </div> |
| |
| <div id="pane-url" class="hidden"> |
| <p class="label mb-2">URL Video diretto</p> |
| <input id="vurl" type="text" placeholder="https://esempio.com/video.mp4" class="w-full px-4 py-3 rounded-xl text-sm"> |
| <p class="hint">Solo HTTP/HTTPS. Niente <code class="text-violet-400">blob:</code></p> |
| </div> |
| |
| <div class="grid grid-cols-2 gap-3"> |
| <div><p class="label mb-2">Lingua</p> |
| <select id="lang" class="w-full px-3 py-2.5 rounded-xl text-sm">__LANG_OPTIONS__</select></div> |
| <div><p class="label mb-2">Voce</p> |
| <select id="gender" class="w-full px-3 py-2.5 rounded-xl text-sm"> |
| <option value="Female">Femminile</option><option value="Male">Maschile</option></select></div> |
| </div> |
| |
| <div><p class="label mb-2">Modalità Dub</p> |
| <select id="mode" class="w-full px-3 py-2.5 rounded-xl text-sm"> |
| <option value="replace">Replace</option> |
| <option value="mix">Mix</option> |
| <option value="advanced">Advanced</option> |
| </select></div> |
| |
| <details class="text-sm"> |
| <summary class="cursor-pointer text-slate-400 hover:text-slate-200 select-none">Parametri avanzati</summary> |
| <div class="mt-3 space-y-3"> |
| <div class="grid grid-cols-3 gap-2"> |
| <div><p class="label mb-1">Rate</p><input id="rate" value="+0%" class="w-full px-2 py-2 rounded-lg text-xs"></div> |
| <div><p class="label mb-1">Pitch</p><input id="pitch" value="+0Hz" class="w-full px-2 py-2 rounded-lg text-xs"></div> |
| <div><p class="label mb-1">Vol TTS</p><input id="vol" value="+0%" class="w-full px-2 py-2 rounded-lg text-xs"></div> |
| </div> |
| <div class="grid grid-cols-3 gap-2"> |
| <div><p class="label mb-1">Dub vol</p><input id="dubv" type="number" value="0.85" step="0.05" min="0" max="1" class="w-full px-2 py-2 rounded-lg text-xs"></div> |
| <div><p class="label mb-1">Orig vol</p><input id="origv" type="number" value="0.15" step="0.05" min="0" max="1" class="w-full px-2 py-2 rounded-lg text-xs"></div> |
| <div><p class="label mb-1">Fade</p><input id="fade" type="number" value="1.0" step="0.5" min="0" max="5" class="w-full px-2 py-2 rounded-lg text-xs"></div> |
| </div> |
| </div> |
| </details> |
| |
| <button onclick="start()" id="btnStart" class="btn w-full py-3.5 rounded-xl font-semibold">Avvia doppiaggio</button> |
| <button onclick="cancel()" id="btnCancel" class="hidden w-full py-2.5 rounded-xl font-semibold bg-red-500/20 border border-red-500/30 text-red-300 hover:bg-red-500/30">Annulla</button> |
| |
| </div> |
| |
| <div class="glass rounded-2xl p-6 space-y-4"> |
| |
| <div> |
| <div class="flex justify-between text-xs text-slate-400 mb-2"> |
| <span><span id="globalStage">In attesa</span><span id="hb" class="heartbeat" style="display:none"></span></span> |
| <span id="globalPct" class="code text-violet-300">0.00 %</span> |
| </div> |
| <div class="bar-track"><div id="globalBar" class="bar-fill"></div></div> |
| </div> |
| |
| <div class="space-y-2"> |
| <div class="step-row" id="row0"> |
| <div class="step-num" id="num0">↑</div> |
| <div class="flex-1 min-w-0"> |
| <div class="flex justify-between items-center"><p class="text-sm font-medium">Upload</p><span id="s0pct" class="code text-violet-300">—</span></div> |
| <div class="bar-track mt-1.5"><div id="s0bar" class="bar-fill upload"></div></div> |
| <p id="s0msg" class="step-msg">—</p> |
| </div> |
| </div> |
| |
| <div class="step-row" id="row1"> |
| <div class="step-num" id="num1">1</div> |
| <div class="flex-1 min-w-0"> |
| <div class="flex justify-between items-center"><p class="text-sm font-medium">Extract SRT</p><span id="s1pct" class="code text-violet-300">—</span></div> |
| <div class="bar-track mt-1.5"><div id="s1bar" class="bar-fill step"></div></div> |
| <p id="s1msg" class="step-msg">—</p> |
| </div> |
| </div> |
| |
| <div class="step-row" id="row2"> |
| <div class="step-num" id="num2">2</div> |
| <div class="flex-1 min-w-0"> |
| <div class="flex justify-between items-center"><p class="text-sm font-medium">Translate</p><span id="s2pct" class="code text-violet-300">—</span></div> |
| <div class="bar-track mt-1.5"><div id="s2bar" class="bar-fill step"></div></div> |
| <p id="s2msg" class="step-msg">—</p> |
| </div> |
| </div> |
| |
| <div class="step-row" id="row3"> |
| <div class="step-num" id="num3">3</div> |
| <div class="flex-1 min-w-0"> |
| <div class="flex justify-between items-center"><p class="text-sm font-medium">Synced TTS</p><span id="s3pct" class="code text-violet-300">—</span></div> |
| <div class="bar-track mt-1.5"><div id="s3bar" class="bar-fill step"></div></div> |
| <p id="s3msg" class="step-msg">—</p> |
| </div> |
| </div> |
| |
| <div class="step-row" id="row4"> |
| <div class="step-num" id="num4">4</div> |
| <div class="flex-1 min-w-0"> |
| <div class="flex justify-between items-center"><p class="text-sm font-medium">Dub Video</p><span id="s4pct" class="code text-violet-300">—</span></div> |
| <div class="bar-track mt-1.5"><div id="s4bar" class="bar-fill step"></div></div> |
| <p id="s4msg" class="step-msg">—</p> |
| </div> |
| </div> |
| </div> |
| |
| <p id="msg" class="text-xs text-slate-500 code break-all min-h-[16px]"></p> |
| |
| <details class="text-xs"> |
| <summary class="cursor-pointer text-slate-500 hover:text-violet-300 select-none">🔍 Debug console (apri F12 per dettagli)</summary> |
| <pre id="debugLog" class="mt-2 p-2 bg-black/40 rounded text-[10px] text-emerald-300 max-h-32 overflow-y-auto code"></pre> |
| </details> |
| |
| <div id="result" class="hidden space-y-3 pt-3 border-t border-white/5"> |
| <video id="player" controls class="w-full rounded-xl border border-white/10"></video> |
| <div class="dl-row text-xs"> |
| <a id="dlVideo" class="btn btn-download rounded-lg font-semibold" title="Scarica video doppiato"><span>Video MP4</span></a> |
| <a id="dlSrt" target="_blank" class="btn-download rounded-lg font-semibold bg-white/5 border border-white/10 hover:bg-white/10" title="Scarica sottotitoli tradotti"><span>SRT tradotto</span></a> |
| </div> |
| </div> |
| |
| </div> |
| </div> |
| |
| <div class="glass rounded-2xl p-5 mt-5 text-xs"> |
| <p class="label mb-3">REST API — Esempi</p> |
| <div class="space-y-2 text-slate-400 code"> |
| <div><span class="text-emerald-400">GET</span> <code class="text-violet-300">/api/dub?video=URL&lang=it&wait=true</code> — sincrono</div> |
| <div><span class="text-emerald-400">GET</span> <code class="text-violet-300">/api/dub?video=URL&lang=it</code> → restituisce <code>job_id</code></div> |
| <div><span class="text-blue-400">POST</span> <code class="text-violet-300">/api/dub</code> (multipart: video=@file.mp4)</div> |
| <div><span class="text-emerald-400">GET</span> <code class="text-violet-300">/status/{job_id}</code> — polling</div> |
| <div><span class="text-emerald-400">GET</span> <code class="text-violet-300">/progress/{job_id}</code> — SSE stream</div> |
| </div> |
| </div> |
| |
| <footer class="text-center mt-8 text-slate-500 text-xs space-x-5"> |
| <a href="/docs" class="hover:text-violet-300">Swagger</a> |
| <a href="/redoc" class="hover:text-violet-300">ReDoc</a> |
| <a href="/health" class="hover:text-violet-300">Health</a> |
| <a href="/languages" class="hover:text-violet-300">Lingue</a> |
| </footer> |
| </div> |
| |
| <script> |
| const $ = id => document.getElementById(id); |
| const MAX_MB = __MAX_MB__; |
| let currentJob = null, currentES = null, currentXHR = null; |
| let uploadStartTime = 0; |
| |
| // Sistema tween: interpola tra valore corrente e target ad ogni frame |
| const targetBars = {global: 0, s0: 0, s1: 0, s2: 0, s3: 0, s4: 0}; |
| const currentBars = {global: 0, s0: 0, s1: 0, s2: 0, s3: 0, s4: 0}; |
| |
| function tweenStep(){ |
| const SPEED = 0.35; // 35% del delta = più reattivo |
| for(const k of Object.keys(targetBars)){ |
| const target = targetBars[k]; |
| const current = currentBars[k]; |
| const delta = target - current; |
| if(Math.abs(delta) > 0.01){ |
| currentBars[k] = current + delta * SPEED; |
| } else { |
| currentBars[k] = target; |
| } |
| const val = currentBars[k]; |
| if(k === 'global'){ |
| $('globalBar').style.width = val.toFixed(2)+'%'; |
| $('globalPct').textContent = val.toFixed(2)+' %'; |
| } else { |
| const bar = document.getElementById(k+'bar'); |
| const pct = document.getElementById(k+'pct'); |
| if(bar) bar.style.width = val.toFixed(2)+'%'; |
| if(pct) pct.textContent = val.toFixed(2)+'%'; |
| } |
| } |
| requestAnimationFrame(tweenStep); |
| } |
| requestAnimationFrame(tweenStep); |
| |
| function log(...a){ |
| console.log('[DubFlow]', ...a); |
| const dl = document.getElementById('debugLog'); |
| if(dl){ |
| const line = '['+new Date().toLocaleTimeString()+'] '+a.map(x=>typeof x==='object'?JSON.stringify(x):String(x)).join(' '); |
| dl.textContent = (line+'\n'+dl.textContent).slice(0, 4000); |
| } |
| } |
| |
| // Intercetta anche console.log/warn/error per debug panel |
| (function(){ |
| const _log = console.log; |
| const _warn = console.warn; |
| const _err = console.error; |
| const append = (level, args) => { |
| const dl = document.getElementById('debugLog'); |
| if(!dl) return; |
| const line = '['+new Date().toLocaleTimeString()+'] ['+level+'] '+Array.from(args).map(x=>typeof x==='object'?JSON.stringify(x):String(x)).join(' '); |
| dl.textContent = (line+'\n'+dl.textContent).slice(0, 4000); |
| }; |
| console.log = function(...a){ _log.apply(console,a); append('LOG',a); }; |
| console.warn = function(...a){ _warn.apply(console,a); append('WRN',a); }; |
| console.error = function(...a){ _err.apply(console,a); append('ERR',a); }; |
| })(); |
| |
| function toast(msg, type='err', dur=5000){ |
| const t=document.createElement('div');t.className='toast '+type;t.textContent=msg; |
| document.body.appendChild(t); |
| setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),300);},dur); |
| } |
| |
| function tab(n){ |
| ['file','url'].forEach(t=>{$('pane-'+t).classList.toggle('hidden', t!==n);$('t-'+t).classList.toggle('active', t===n);}); |
| } |
| |
| (function(){ |
| const z=$('drop'),i=$('vfile'); |
| z.onclick=()=>i.click(); |
| z.ondragover=e=>{e.preventDefault();z.classList.add('drag');}; |
| z.ondragleave=()=>z.classList.remove('drag'); |
| z.ondrop=e=>{e.preventDefault();z.classList.remove('drag'); |
| if(e.dataTransfer.files.length){i.files=e.dataTransfer.files;showFile(i.files[0]);}}; |
| i.onchange=()=>{if(i.files[0]) showFile(i.files[0]);}; |
| })(); |
| |
| function showFile(f){ |
| const mb=(f.size/1048576).toFixed(1); |
| $('fname').textContent = `${f.name} — ${mb} MB`; |
| if(f.size > MAX_MB*1048576){$('fname').classList.add('text-red-400');} |
| else {$('fname').classList.remove('text-red-400');$('fname').classList.add('text-emerald-400');} |
| } |
| |
| function setBar(id, pct){ |
| const p=Math.max(0,Math.min(100,pct)); |
| $(id+'bar').style.width=p.toFixed(2)+'%'; |
| $(id+'pct').textContent=p.toFixed(1)+'%'; |
| } |
| |
| function setStep(n, status){ |
| const el=$('num'+n), row=$('row'+n); |
| el.classList.remove('active','done');row.classList.remove('active'); |
| if(status==='active'){el.classList.add('active');row.classList.add('active');} |
| if(status==='done'){el.classList.add('done'); el.textContent=n===0?'↑':'✓';} |
| else el.textContent=n===0?'↑':String(n); |
| } |
| |
| function resetUI(){ |
| for(let i=0;i<=4;i++){ |
| setStep(i,'idle'); |
| targetBars['s'+i]=0; |
| currentBars['s'+i]=0; |
| const bar=document.getElementById('s'+i+'bar'); |
| const pct=document.getElementById('s'+i+'pct'); |
| const msg=document.getElementById('s'+i+'msg'); |
| const row=document.getElementById('row'+i); |
| if(bar) bar.style.width='0%'; |
| if(pct) pct.textContent='—'; |
| if(msg){msg.textContent='—'; delete msg.dataset.locked;} |
| if(row) row.classList.remove('active', 'done-row'); |
| } |
| targetBars['global']=0; |
| currentBars['global']=0; |
| $('globalBar').style.width='0%'; |
| $('globalPct').textContent='0.00 %'; |
| $('globalStage').textContent='Avvio'; |
| $('result').classList.add('hidden'); |
| $('msg').textContent=''; |
| $('hb').style.display='none'; |
| } |
| |
| function track(jid){ |
| log('tracking', jid); |
| currentJob=jid; |
| $('btnCancel').classList.remove('hidden');$('btnStart').disabled=true; |
| if(currentES){try{currentES.close();}catch{}} |
| |
| let lastSSETime = Date.now(); |
| let pollFallbackTimer = null; |
| let sseConnected = false; |
| |
| const handleUpdate = (d, source) => { |
| if(d.error){ |
| toast('Errore: '+d.error,'err',10000); |
| $('btnCancel').classList.add('hidden');$('btnStart').disabled=false; |
| $('hb').style.display='none'; |
| if(currentES) try{currentES.close();}catch{} |
| if(pollFallbackTimer) clearInterval(pollFallbackTimer); |
| return false; |
| } |
| |
| // DEBUG: log ogni messaggio |
| console.log(`[${source}] step=${d.step} progress=${d.progress} step_progress=${d.step_progress} msg="${d.message}"`); |
| |
| const gp = Number(d.progress) || 0; |
| const curStep = d.step !== undefined ? Number(d.step) : 0; |
| const curStepProg = Number(d.step_progress) || 0; |
| const curMsg = d.message || ''; |
| |
| targetBars['global'] = gp; |
| $('globalStage').textContent = d.stage || ''; |
| $('msg').textContent = `[${source}] step=${curStep} ${curStepProg.toFixed(2)}% · ${curMsg}`; |
| |
| for(let i=0; i<=4; i++){ |
| const msgEl = document.getElementById('s'+i+'msg'); |
| const rowEl = document.getElementById('row'+i); |
| |
| if(curStep > i){ |
| setStep(i, 'done'); |
| targetBars['s'+i] = 100; |
| currentBars['s'+i] = 100; |
| if(rowEl){rowEl.classList.add('done-row'); rowEl.classList.remove('active');} |
| if(msgEl && !msgEl.dataset.locked){ |
| msgEl.textContent = '✓ completato'; |
| msgEl.dataset.locked = '1'; |
| } |
| } |
| else if(curStep === i){ |
| setStep(i, 'active'); |
| targetBars['s'+i] = curStepProg; |
| if(rowEl){rowEl.classList.add('active'); rowEl.classList.remove('done-row');} |
| if(msgEl){ |
| msgEl.textContent = curMsg || '...'; |
| delete msgEl.dataset.locked; |
| } |
| } |
| else { |
| setStep(i, 'idle'); |
| targetBars['s'+i] = 0; |
| currentBars['s'+i] = 0; |
| if(rowEl){rowEl.classList.remove('active', 'done-row');} |
| if(msgEl){ |
| msgEl.textContent = '—'; |
| delete msgEl.dataset.locked; |
| } |
| } |
| } |
| |
| if(d.done && d.result){ |
| for(let i=0;i<=4;i++){ |
| setStep(i,'done'); |
| targetBars['s'+i]=100; |
| currentBars['s'+i]=100; |
| const bar=document.getElementById('s'+i+'bar'); |
| const pct=document.getElementById('s'+i+'pct'); |
| const msg=document.getElementById('s'+i+'msg'); |
| const row=document.getElementById('row'+i); |
| if(bar) bar.style.width='100%'; |
| if(pct) pct.textContent='100.00%'; |
| if(msg) msg.textContent='✓ completato'; |
| if(row){row.classList.add('done-row'); row.classList.remove('active');} |
| } |
| targetBars['global']=100; |
| currentBars['global']=100; |
| $('globalBar').style.width='100%'; |
| $('globalPct').textContent='100.00 %'; |
| $('globalStage').textContent='completato'; |
| $('hb').style.display='none'; |
| $('result').classList.remove('hidden'); |
| $('player').src=d.result.video_url; |
| $('dlVideo').href=d.result.video_download; |
| if(d.result.video_filename){ |
| $('dlVideo').setAttribute('download', d.result.video_filename); |
| $('dlVideo').textContent = '⬇ ' + d.result.video_filename; |
| } |
| $('dlSrt').href=d.result.srt_translated; |
| $('btnCancel').classList.add('hidden'); |
| $('btnStart').disabled=false; |
| toast('Completato in '+d.result.elapsed_sec+'s','ok'); |
| if(currentES) try{currentES.close();}catch{} |
| if(pollFallbackTimer) clearInterval(pollFallbackTimer); |
| return false; |
| } |
| return true; |
| }; |
| |
| // POLLING FALLBACK — si attiva se SSE non riceve nulla per >3s |
| const startPollingFallback = () => { |
| if(pollFallbackTimer) return; |
| console.warn('[FALLBACK] Starting polling /status (SSE silent)'); |
| $('msg').textContent = '[FALLBACK POLLING ATTIVO] SSE non riceve eventi…'; |
| pollFallbackTimer = setInterval(async () => { |
| if(currentJob !== jid){ |
| clearInterval(pollFallbackTimer); |
| return; |
| } |
| try { |
| const r = await fetch('/status/'+jid); |
| if(!r.ok) return; |
| const d = await r.json(); |
| if(!handleUpdate(d, 'POLL')){ |
| clearInterval(pollFallbackTimer); |
| } |
| } catch(e) { |
| console.error('polling error', e); |
| } |
| }, 500); |
| }; |
| |
| // SSE CONNECTION |
| const connectSSE = () => { |
| console.log('[SSE] connecting…'); |
| const es = new EventSource('/progress/' + jid); |
| currentES = es; |
| $('hb').style.display = 'inline-block'; |
| |
| es.onopen = () => { |
| console.log('[SSE] connected'); |
| sseConnected = true; |
| lastSSETime = Date.now(); |
| }; |
| |
| es.onmessage = e => { |
| lastSSETime = Date.now(); |
| sseConnected = true; |
| if(pollFallbackTimer){ |
| console.log('[SSE] recovered, stopping polling fallback'); |
| clearInterval(pollFallbackTimer); |
| pollFallbackTimer = null; |
| } |
| let d; |
| try{ d = JSON.parse(e.data); } |
| catch(err){ console.error('SSE parse error', err, e.data); return; } |
| if(!handleUpdate(d, 'SSE')){ |
| es.close(); |
| } |
| }; |
| |
| es.onerror = e => { |
| console.warn('[SSE] error', e, 'state=', es.readyState); |
| $('hb').style.display='none'; |
| sseConnected = false; |
| // Non chiudere subito, EventSource fa auto-reconnect |
| // Ma se rimaniamo disconnessi >3s, attiva polling |
| setTimeout(() => { |
| if(!sseConnected && currentJob === jid){ |
| startPollingFallback(); |
| } |
| }, 3000); |
| }; |
| }; |
| |
| // Watchdog: se SSE non riceve nulla per 3s, attiva polling |
| const watchdog = setInterval(() => { |
| if(currentJob !== jid){ |
| clearInterval(watchdog); |
| return; |
| } |
| const silenceTime = (Date.now() - lastSSETime) / 1000; |
| if(silenceTime > 3 && !pollFallbackTimer){ |
| console.warn(`[WATCHDOG] SSE silent for ${silenceTime.toFixed(1)}s, starting polling`); |
| startPollingFallback(); |
| } |
| }, 1000); |
| |
| connectSSE(); |
| } |
| |
| function getParams(){ |
| return { |
| lang: $('lang').value, gender: $('gender').value, mode: $('mode').value, |
| dub_volume: $('dubv').value, orig_volume: $('origv').value, fade: $('fade').value, |
| rate: $('rate').value, pitch: $('pitch').value, volume: $('vol').value, |
| }; |
| } |
| |
| async function parseErr(r){ |
| try{const j=await r.json();return j.detail||j.error||JSON.stringify(j);} |
| catch{return (await r.text()).slice(0,200)||`HTTP ${r.status}`;} |
| } |
| |
| function uploadFile(jid, file){ |
| return new Promise((resolve,reject)=>{ |
| const xhr=new XMLHttpRequest(); |
| currentXHR=xhr;uploadStartTime=Date.now(); |
| xhr.upload.onprogress=e=>{ |
| if(!e.lengthComputable) return; |
| const pct=(e.loaded/e.total)*100; |
| targetBars['s0']=pct; |
| setStep(0,'active'); |
| const elapsed=(Date.now()-uploadStartTime)/1000; |
| const speed=e.loaded/elapsed/1048576; |
| const eta=e.loaded>0 ? (e.total-e.loaded)/(e.loaded/elapsed) : 0; |
| const mb=(e.loaded/1048576).toFixed(2); |
| const tot=(e.total/1048576).toFixed(2); |
| const msg = `${mb}/${tot} MB · ${speed.toFixed(2)} MB/s · ETA ${eta.toFixed(1)}s`; |
| const s0msg = document.getElementById('s0msg'); |
| if(s0msg){s0msg.textContent = msg; delete s0msg.dataset.locked;} |
| const rowEl = document.getElementById('row0'); |
| if(rowEl) rowEl.classList.add('active'); |
| }; |
| xhr.upload.onload=()=>{ |
| const s0msg = document.getElementById('s0msg'); |
| if(s0msg) s0msg.textContent = 'upload completato, attesa server…'; |
| }; |
| xhr.onload=()=>{ |
| currentXHR=null; |
| if(xhr.status>=200 && xhr.status<300){try{resolve(JSON.parse(xhr.responseText));}catch{resolve({});}} |
| else {try{const j=JSON.parse(xhr.responseText);reject(new Error(j.detail||xhr.statusText));}catch{reject(new Error(xhr.responseText||xhr.statusText));}} |
| }; |
| xhr.onerror=()=>{currentXHR=null;reject(new Error('Errore rete'));}; |
| xhr.onabort=()=>{currentXHR=null;reject(new Error('Annullato'));}; |
| xhr.ontimeout=()=>{currentXHR=null;reject(new Error('Timeout'));}; |
| xhr.timeout=30*60*1000; |
| xhr.open('POST',`/job/${jid}/upload`); |
| const fd=new FormData(); fd.append('video',file); |
| xhr.send(fd); |
| }); |
| } |
| |
| async function start(){ |
| const isFile = !$('pane-file').classList.contains('hidden'); |
| const p = getParams(); |
| if(isFile){ |
| const f=$('vfile').files[0]; |
| if(!f){toast('Seleziona video');return;} |
| if(f.size<1000){toast('File troppo piccolo');return;} |
| if(f.size>MAX_MB*1048576){toast(`Max ${MAX_MB} MB`);return;} |
| } else { |
| const u=$('vurl').value.trim(); |
| if(!u){toast('Inserisci URL');return;} |
| if(u.startsWith('blob:')){toast('blob: non supportato','err',9000);return;} |
| if(!/^https?:\/\//i.test(u)){toast('URL http/https');return;} |
| } |
| resetUI();$('btnStart').disabled=true; |
| try { |
| const qs=new URLSearchParams(p).toString(); |
| const r=await fetch('/job/create?'+qs,{method:'POST'}); |
| if(!r.ok){toast('Errore: '+await parseErr(r));$('btnStart').disabled=false;return;} |
| const {job_id}=await r.json(); |
| track(job_id); |
| await new Promise(r=>setTimeout(r,200)); |
| if(isFile){ |
| try { await uploadFile(job_id, $('vfile').files[0]); } |
| catch(e){ toast('Upload fallito: '+e.message,'err',8000); return; } |
| } else { |
| const u=$('vurl').value.trim(); |
| setStep(0,'active'); |
| const ur=await fetch(`/job/${job_id}/url?video=${encodeURIComponent(u)}`,{method:'POST'}); |
| if(!ur.ok){toast('Download fallito: '+await parseErr(ur),'err',8000);return;} |
| } |
| } catch(e){ |
| toast('Errore: '+e.message,'err');$('btnStart').disabled=false; |
| } |
| } |
| |
| async function cancel(){ |
| if(currentXHR){try{currentXHR.abort();}catch{}} |
| if(currentJob){try{await fetch('/cancel/'+currentJob,{method:'POST'});}catch{} toast('Annullato','info');} |
| $('btnCancel').classList.add('hidden');$('btnStart').disabled=false; |
| } |
| </script> |
| </body></html>""" |
|
|
|
|
| if __name__ == "__main__": |
| import uvicorn |
| uvicorn.run("app:app", host="0.0.0.0", port=7860, workers=1, log_level="info") |