elmarcito commited on
Commit
e89541d
·
verified ·
1 Parent(s): 0e81edb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +120 -81
app.py CHANGED
@@ -25,8 +25,8 @@ os.makedirs(PUBLIC, exist_ok=True)
25
  FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
26
  FFPROBE = shutil.which("ffprobe") or "ffprobe"
27
 
28
- TTS_CONCURRENCY = 4
29
- TRANS_CONCURRENCY = 12
30
  FFMPEG_THREADS = 4
31
  CHUNK_SIZE = 1024 * 512
32
 
@@ -43,7 +43,7 @@ THREAD_POOL = ThreadPoolExecutor(max_workers=16)
43
  @asynccontextmanager
44
  async def lifespan(app: FastAPI):
45
  task = asyncio.create_task(periodic_cleanup())
46
- log.info("DubFlow v2.4.8 started")
47
  yield
48
  task.cancel()
49
  THREAD_POOL.shutdown(wait=False)
@@ -51,7 +51,7 @@ async def lifespan(app: FastAPI):
51
 
52
  app = FastAPI(
53
  title="DubFlow API",
54
- version="2.4.8",
55
  description="Video dubbing pipeline — MP4 → SRT → Translate → TTS → Dubbed MP4",
56
  lifespan=lifespan,
57
  )
@@ -143,15 +143,17 @@ def new_job():
143
  "cancel": False, "created": time.time(),
144
  "video_ready": False, "video_path": None, "params": None,
145
  "last_heartbeat": time.time(),
 
146
  }
147
  log.info(f"[{jid[:8]}] job created")
148
  return jid
149
 
150
  def compute_global(step, step_pct):
 
151
  done = sum(STEP_WEIGHTS[s] for s in STEP_WEIGHTS if s < step)
152
  if step in STEP_WEIGHTS:
153
  done += STEP_WEIGHTS[step] * (step_pct / 100.0)
154
- return min(100.0, done)
155
 
156
  def update(jid, **kw):
157
  j = JOBS.get(jid)
@@ -599,7 +601,7 @@ async def step1_extract_srt(video_path, jid):
599
  if not chunk: break
600
  sent += len(chunk)
601
  now = time.time()
602
- if now - last_t > 0.3:
603
  pct = 15 + (sent/total) * 25
604
  update(jid, step_progress=round(pct, 2),
605
  message=f"Upload audio {sent/1048576:.1f}/{total/1048576:.1f} MB")
@@ -896,8 +898,18 @@ def speed_fit(inp, out, target):
896
  if res.returncode != 0:
897
  shutil.copy2(inp, out)
898
 
 
 
 
 
 
 
 
 
 
 
899
  async def _gtts_fallback(text, lang_code, out):
900
- """Fallback su gTTS - sempre funziona da qualsiasi IP."""
901
  loop = asyncio.get_event_loop()
902
 
903
  def _sync_gtts():
@@ -908,42 +920,45 @@ async def _gtts_fallback(text, lang_code, out):
908
  sp.check_call([sys.executable, "-m", "pip", "install", "-q", "gTTS"])
909
  from gtts import gTTS
910
 
911
- gtts_lang_map = {
912
- "nb": "no",
913
- "zh": "zh-cn",
914
- }
915
  lang = gtts_lang_map.get(lang_code, lang_code)
916
 
917
  try:
918
- tts = gTTS(text=text, lang=lang, slow=False)
919
  tts.save(out)
920
  except Exception:
921
  try:
922
  tts = gTTS(text=text, lang="en", slow=False)
923
  tts.save(out)
924
  except Exception as e:
925
- raise RuntimeError(f"gTTS totally failed: {e}")
926
 
927
  await loop.run_in_executor(THREAD_POOL, _sync_gtts)
928
 
929
  if not os.path.exists(out) or os.path.getsize(out) < 100:
930
- raise RuntimeError("gTTS produced empty file")
 
 
 
 
931
 
932
 
933
  async def tts_block(text, voice, out, rate, pitch, volume):
934
- """TTS con edge-tts + fallback gTTS automatico se 403."""
935
- import random
936
- last_err = None
 
 
 
 
 
 
937
 
938
  lang_code = voice.split("-")[0].lower() if "-" in voice else "en"
939
 
940
- # Tentativo 1-3: Edge TTS
941
- edge_blocked = False
942
- for attempt in range(3):
943
  try:
944
- if attempt > 0:
945
- await asyncio.sleep(random.uniform(0.5, 1.5) * attempt)
946
-
947
  comm = edge_tts.Communicate(
948
  text=text, voice=voice,
949
  rate=rate, pitch=pitch, volume=volume,
@@ -952,28 +967,23 @@ async def tts_block(text, voice, out, rate, pitch, volume):
952
  async for c in comm.stream():
953
  if c["type"] == "audio":
954
  data.extend(c["data"])
955
- if not data:
956
- raise RuntimeError("empty audio")
957
- async with aiofiles.open(out, "wb") as f:
958
- await f.write(bytes(data))
959
- return # SUCCESS
 
 
960
  except Exception as e:
961
- last_err = e
962
- err_str = str(e)
963
- if "403" in err_str or "Invalid response status" in err_str:
964
- edge_blocked = True
965
- break
966
-
967
- # log silenzioso, edge-tts è sempre bloccato su HF Spaces
968
-
969
- # Fallback: gTTS
970
- try:
971
- await _gtts_fallback(text, lang_code, out)
972
- return # SUCCESS via gTTS
973
- except Exception as e:
974
- last_err = e
975
 
976
- raise RuntimeError(f"TTS failed (edge+gtts): {last_err}")
 
 
 
 
977
 
978
 
979
  async def step3_tts(srt, lang, gender, out_mp3, jid, rate, pitch, volume):
@@ -1096,6 +1106,10 @@ async def step4_dub(video, audio, out, mode, dub_vol, orig_vol, fade, jid):
1096
  # ══════════════════════════════════════════════════════════════
1097
  # PIPELINE
1098
  # ══════════════════════════════════════════════════════════════
 
 
 
 
1099
  async def run_pipeline(jid, base_url):
1100
  j = JOBS[jid]
1101
  log.info(f"[{jid[:8]}] pipeline waiting for video…")
@@ -1108,18 +1122,27 @@ async def run_pipeline(jid, base_url):
1108
  if time.time() - wait_start > 1200:
1109
  update(jid, error="Timeout attesa video", done=True)
1110
  return
1111
- await asyncio.sleep(0.1)
1112
 
1113
  log.info(f"[{jid[:8]}] video ready, starting pipeline")
1114
  video_path = j["video_path"]
1115
  p = j["params"]
1116
  target_lang = p["lang"]
1117
 
1118
- uid = uuid.uuid4().hex
1119
- tts_path = os.path.join(TMP, f"dub_tts_{uid}.mp3")
1120
- out_path = os.path.join(PUBLIC, f"{uid}.mp4")
1121
- srt_path = os.path.join(PUBLIC, f"{uid}.srt")
1122
- tsrt_path = os.path.join(PUBLIC, f"{uid}_{target_lang}.srt")
 
 
 
 
 
 
 
 
 
1123
 
1124
  t0 = time.time()
1125
  try:
@@ -1139,17 +1162,18 @@ async def run_pipeline(jid, base_url):
1139
 
1140
  elapsed = time.time() - t0
1141
  update(jid, stage="done", done=True, step=4, step_progress=100,
1142
- message=f"Completato in {elapsed:.1f}s",
1143
  result={
1144
- "video_url": f"{base_url}/file/{uid}.mp4",
1145
- "video_download": f"{base_url}/file/{uid}.mp4?dl=1",
1146
- "srt_original": f"{base_url}/file/{uid}.srt",
1147
- "srt_translated": f"{base_url}/file/{uid}_{target_lang}.srt",
 
1148
  "language": target_lang, "gender": p["gender"],
1149
- "elapsed_sec": round(elapsed, 1),
1150
  })
1151
  JOBS[jid]["progress"] = 100.0
1152
- log.info(f"[{jid[:8]}] ✅ done in {elapsed:.1f}s")
1153
  except InterruptedError:
1154
  log.info(f"[{jid[:8]}] cancelled")
1155
  update(jid, error="Cancellato", done=True)
@@ -1160,9 +1184,7 @@ async def run_pipeline(jid, base_url):
1160
  cleanup_paths([video_path, tts_path])
1161
 
1162
 
1163
- # ══════════════════════════════════════════════════════════════
1164
- # SSE PROGRESS
1165
- # ══════════════════════════════════════════════════════════════
1166
  @app.get("/progress/{jid}", tags=["Progress"])
1167
  async def progress(jid: str):
1168
  """Server-Sent Events stream con stato job in real-time."""
@@ -1178,13 +1200,17 @@ async def progress(jid: str):
1178
  ("progress","step","step_progress","message","stage","done","error","result")}
1179
  s = json.dumps(payload)
1180
  now = time.time()
1181
- if s != last_send or (now - last_time) > 2.0:
 
1182
  yield f"data: {s}\n\n"
1183
  last_send = s
1184
  last_time = now
 
 
 
1185
  if j["done"] or j["error"]:
1186
  return
1187
- await asyncio.sleep(0.15)
1188
  return StreamingResponse(gen(), media_type="text/event-stream",
1189
  headers={"Cache-Control":"no-cache",
1190
  "X-Accel-Buffering":"no",
@@ -1232,14 +1258,20 @@ async def job_create(request: Request,
1232
  base = str(request.base_url).rstrip("/")
1233
  asyncio.create_task(run_pipeline(jid, base))
1234
  return {"job_id": jid, "status_url": f"{base}/status/{jid}", "progress_url": f"{base}/progress/{jid}"}
1235
-
1236
-
1237
  @app.post("/job/{jid}/upload", tags=["Jobs"])
1238
  async def job_upload(jid: str, video: UploadFile = File(...)):
1239
  j = JOBS.get(jid)
1240
  if not j: raise HTTPException(404, "job not found")
1241
  if j["video_ready"]: raise HTTPException(400, "video già caricato")
1242
 
 
 
 
 
 
 
 
 
1243
  uid = uuid.uuid4().hex
1244
  vpath = os.path.join(TMP, f"dub_in_{uid}.mp4")
1245
  size = 0
@@ -1255,9 +1287,9 @@ async def job_upload(jid: str, video: UploadFile = File(...)):
1255
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1256
  raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB")
1257
  now = time.time()
1258
- if now - last_t > 0.2:
1259
  update(jid, step=0, step_progress=min(95, size/(MAX_UPLOAD_MB*1024*1024)*95),
1260
- message=f"Ricezione server {size/1048576:.1f} MB")
1261
  last_t = now
1262
 
1263
  if size < MIN_FILE_BYTES:
@@ -1265,10 +1297,10 @@ async def job_upload(jid: str, video: UploadFile = File(...)):
1265
 
1266
  update(jid, step_progress=97, message="Validazione…")
1267
  await validate_video(vpath)
1268
- log.info(f"[{jid[:8]}] upload OK: {size/1048576:.1f} MB")
1269
- update(jid, step=0, step_progress=100, message=f"Video pronto ({size/1048576:.1f} MB)",
1270
  video_path=vpath, video_ready=True)
1271
- return {"ok": True, "size_mb": round(size/1048576, 2)}
1272
  except HTTPException as e:
1273
  cleanup_paths([vpath])
1274
  update(jid, error=str(e.detail), done=True)
@@ -1278,8 +1310,6 @@ async def job_upload(jid: str, video: UploadFile = File(...)):
1278
  cleanup_paths([vpath])
1279
  update(jid, error=str(e), done=True)
1280
  raise HTTPException(500, str(e))
1281
-
1282
-
1283
  @app.post("/job/{jid}/url", tags=["Jobs"])
1284
  async def job_url(jid: str, video: str = Query(...)):
1285
  j = JOBS.get(jid)
@@ -1294,6 +1324,15 @@ async def job_url(jid: str, video: str = Query(...)):
1294
  update(jid, error="URL non valido", done=True)
1295
  raise HTTPException(400, "URL deve iniziare con http/https")
1296
 
 
 
 
 
 
 
 
 
 
1297
  uid = uuid.uuid4().hex
1298
  vpath = os.path.join(TMP, f"dub_in_{uid}.mp4")
1299
 
@@ -1315,21 +1354,21 @@ async def job_url(jid: str, video: str = Query(...)):
1315
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1316
  raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB")
1317
  now = time.time()
1318
- if now - last_t > 0.2:
1319
  pct = (size/total*95) if total else min(95, size/(1024*1024))
1320
  update(jid, step_progress=round(pct, 2),
1321
- message=f"Download {size/1048576:.1f}/{total/1048576:.1f} MB" if total
1322
- else f"Download {size/1048576:.1f} MB")
1323
  last_t = now
1324
 
1325
  if size < MIN_FILE_BYTES:
1326
  raise HTTPException(400, "File troppo piccolo")
1327
  update(jid, step_progress=97, message="Validazione…")
1328
  await validate_video(vpath)
1329
- log.info(f"[{jid[:8]}] download OK: {size/1048576:.1f} MB")
1330
- update(jid, step=0, step_progress=100, message=f"Video pronto ({size/1048576:.1f} MB)",
1331
  video_path=vpath, video_ready=True)
1332
- return {"ok": True, "size_mb": round(size/1048576, 2)}
1333
  except HTTPException as e:
1334
  cleanup_paths([vpath])
1335
  update(jid, error=str(e.detail), done=True)
@@ -1409,7 +1448,7 @@ async def api_dub_get(request: Request,
1409
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1410
  raise Exception(f"Max {MAX_UPLOAD_MB} MB")
1411
  now = time.time()
1412
- if now - last_t > 0.2:
1413
  pct = (size/total*95) if total else min(95, size/(1024*1024))
1414
  update(jid, step_progress=round(pct, 2),
1415
  message=f"Download {size/1048576:.1f} MB")
@@ -1485,7 +1524,7 @@ async def api_dub_post(request: Request,
1485
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1486
  raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB")
1487
  now = time.time()
1488
- if now - last_t > 0.2:
1489
  update(jid, step_progress=min(95, size/(MAX_UPLOAD_MB*1024*1024)*95),
1490
  message=f"Upload {size/1048576:.1f} MB")
1491
  last_t = now
@@ -1549,7 +1588,7 @@ async def voices():
1549
 
1550
  @app.get("/health", tags=["Meta"])
1551
  async def health():
1552
- return {"status":"ok", "version": "2.4.8",
1553
  "jobs_active": sum(1 for j in JOBS.values() if not j["done"]),
1554
  "jobs_total": len(JOBS),
1555
  "max_upload_mb": MAX_UPLOAD_MB}
@@ -1619,7 +1658,7 @@ input:focus,select:focus{border-color:#a78bfa;box-shadow:0 0 0 3px rgba(167,139,
1619
 
1620
  <div class="max-w-5xl mx-auto pt-10 px-6 pb-20">
1621
  <header class="mb-8">
1622
- <p class="label mb-2">Engine v2.4.8 — Real-time + REST API</p>
1623
  <h1 class="text-4xl md:text-5xl font-semibold tracking-tight">DubFlow <span class="text-slate-400 font-light">Web</span></h1>
1624
  <p class="text-slate-400 mt-2 text-sm">Pipeline: upload · estrazione · traduzione · TTS · dub</p>
1625
  </header>
@@ -1891,7 +1930,7 @@ function uploadFile(jid, file){
1891
  const elapsed=(Date.now()-uploadStartTime)/1000;
1892
  const speed=e.loaded/elapsed/1048576;
1893
  const eta=e.loaded>0 ? (e.total-e.loaded)/(e.loaded/elapsed) : 0;
1894
- $('upSpeed').textContent=`${speed.toFixed(1)} MB/s · ETA ${eta.toFixed(0)}s`;
1895
  };
1896
  xhr.upload.onload=()=>{$('upSpeed').textContent='in elaborazione…';};
1897
  xhr.onload=()=>{
 
25
  FFMPEG = shutil.which("ffmpeg") or "ffmpeg"
26
  FFPROBE = shutil.which("ffprobe") or "ffprobe"
27
 
28
+ TTS_CONCURRENCY = 8
29
+ TRANS_CONCURRENCY = 20
30
  FFMPEG_THREADS = 4
31
  CHUNK_SIZE = 1024 * 512
32
 
 
43
  @asynccontextmanager
44
  async def lifespan(app: FastAPI):
45
  task = asyncio.create_task(periodic_cleanup())
46
+ log.info("DubFlow v2.5.0 started")
47
  yield
48
  task.cancel()
49
  THREAD_POOL.shutdown(wait=False)
 
51
 
52
  app = FastAPI(
53
  title="DubFlow API",
54
+ version="2.5.0",
55
  description="Video dubbing pipeline — MP4 → SRT → Translate → TTS → Dubbed MP4",
56
  lifespan=lifespan,
57
  )
 
143
  "cancel": False, "created": time.time(),
144
  "video_ready": False, "video_path": None, "params": None,
145
  "last_heartbeat": time.time(),
146
+ "original_name": "video",
147
  }
148
  log.info(f"[{jid[:8]}] job created")
149
  return jid
150
 
151
  def compute_global(step, step_pct):
152
+ """Calcolo progress globale con precisione decimale."""
153
  done = sum(STEP_WEIGHTS[s] for s in STEP_WEIGHTS if s < step)
154
  if step in STEP_WEIGHTS:
155
  done += STEP_WEIGHTS[step] * (step_pct / 100.0)
156
+ return round(min(100.0, done), 2)
157
 
158
  def update(jid, **kw):
159
  j = JOBS.get(jid)
 
601
  if not chunk: break
602
  sent += len(chunk)
603
  now = time.time()
604
+ if now - last_t > 0.1:
605
  pct = 15 + (sent/total) * 25
606
  update(jid, step_progress=round(pct, 2),
607
  message=f"Upload audio {sent/1048576:.1f}/{total/1048576:.1f} MB")
 
898
  if res.returncode != 0:
899
  shutil.copy2(inp, out)
900
 
901
+ _TTS_CACHE_DIR = os.path.join(TMP, "dubflow_tts_cache")
902
+ os.makedirs(_TTS_CACHE_DIR, exist_ok=True)
903
+
904
+
905
+ def _tts_cache_key(text, voice, rate, pitch, volume):
906
+ """Genera chiave cache stabile per il TTS."""
907
+ key = f"{voice}|{rate}|{pitch}|{volume}|{text}"
908
+ return hashlib.md5(key.encode()).hexdigest()
909
+
910
+
911
  async def _gtts_fallback(text, lang_code, out):
912
+ """Fallback gTTS thread-safe e veloce."""
913
  loop = asyncio.get_event_loop()
914
 
915
  def _sync_gtts():
 
920
  sp.check_call([sys.executable, "-m", "pip", "install", "-q", "gTTS"])
921
  from gtts import gTTS
922
 
923
+ gtts_lang_map = {"nb": "no", "zh": "zh-cn"}
 
 
 
924
  lang = gtts_lang_map.get(lang_code, lang_code)
925
 
926
  try:
927
+ tts = gTTS(text=text, lang=lang, slow=False, tld="com")
928
  tts.save(out)
929
  except Exception:
930
  try:
931
  tts = gTTS(text=text, lang="en", slow=False)
932
  tts.save(out)
933
  except Exception as e:
934
+ raise RuntimeError(f"gTTS failed: {e}")
935
 
936
  await loop.run_in_executor(THREAD_POOL, _sync_gtts)
937
 
938
  if not os.path.exists(out) or os.path.getsize(out) < 100:
939
+ raise RuntimeError("gTTS empty file")
940
+
941
+
942
+ # Flag globale: una volta che edge è bloccato, non riprovare
943
+ _EDGE_BLOCKED = False
944
 
945
 
946
  async def tts_block(text, voice, out, rate, pitch, volume):
947
+ """TTS ottimizzato: cache, edge-tts con skip rapido, gTTS fallback."""
948
+ global _EDGE_BLOCKED
949
+
950
+ # 1. Check cache
951
+ cache_key = _tts_cache_key(text, voice, rate, pitch, volume)
952
+ cache_path = os.path.join(_TTS_CACHE_DIR, f"{cache_key}.mp3")
953
+ if os.path.exists(cache_path) and os.path.getsize(cache_path) > 100:
954
+ shutil.copy2(cache_path, out)
955
+ return
956
 
957
  lang_code = voice.split("-")[0].lower() if "-" in voice else "en"
958
 
959
+ # 2. Edge-tts (skip se già confermato bloccato)
960
+ if not _EDGE_BLOCKED:
 
961
  try:
 
 
 
962
  comm = edge_tts.Communicate(
963
  text=text, voice=voice,
964
  rate=rate, pitch=pitch, volume=volume,
 
967
  async for c in comm.stream():
968
  if c["type"] == "audio":
969
  data.extend(c["data"])
970
+ if data:
971
+ async with aiofiles.open(out, "wb") as f:
972
+ await f.write(bytes(data))
973
+ # Salva in cache
974
+ try: shutil.copy2(out, cache_path)
975
+ except: pass
976
+ return
977
  except Exception as e:
978
+ if "403" in str(e) or "Invalid response" in str(e):
979
+ _EDGE_BLOCKED = True
980
+ log.warning("edge-tts permanently blocked on this IP, using gTTS only")
 
 
 
 
 
 
 
 
 
 
 
981
 
982
+ # 3. Fallback gTTS
983
+ await _gtts_fallback(text, lang_code, out)
984
+ # Cache anche il risultato gTTS
985
+ try: shutil.copy2(out, cache_path)
986
+ except: pass
987
 
988
 
989
  async def step3_tts(srt, lang, gender, out_mp3, jid, rate, pitch, volume):
 
1106
  # ══════════════════════════════════════════════════════════════
1107
  # PIPELINE
1108
  # ══════════════════════════════════════════════════════════════
1109
+ # ══════════════════════════════════════════════════════════════
1110
+ # SSE PROGRESS
1111
+ # ══════════════════════════════════════════════════════════════
1112
+
1113
  async def run_pipeline(jid, base_url):
1114
  j = JOBS[jid]
1115
  log.info(f"[{jid[:8]}] pipeline waiting for video…")
 
1122
  if time.time() - wait_start > 1200:
1123
  update(jid, error="Timeout attesa video", done=True)
1124
  return
1125
+ await asyncio.sleep(0.05)
1126
 
1127
  log.info(f"[{jid[:8]}] video ready, starting pipeline")
1128
  video_path = j["video_path"]
1129
  p = j["params"]
1130
  target_lang = p["lang"]
1131
 
1132
+ # NOMI INTELLIGENTI basati su nome originale
1133
+ original = j.get("original_name", "video")
1134
+ safe_original = re.sub(r'[^\w\-_.]', '_', original)[:60] or "video"
1135
+ uid_short = uuid.uuid4().hex[:8]
1136
+
1137
+ base_name = f"{safe_original}_dubbed_{target_lang}_{uid_short}"
1138
+ srt_orig_name = f"{safe_original}_{uid_short}.srt"
1139
+ srt_trans_name = f"{safe_original}_{target_lang}_{uid_short}.srt"
1140
+ out_mp4_name = f"{base_name}.mp4"
1141
+
1142
+ out_path = os.path.join(PUBLIC, out_mp4_name)
1143
+ srt_path = os.path.join(PUBLIC, srt_orig_name)
1144
+ tsrt_path = os.path.join(PUBLIC, srt_trans_name)
1145
+ tts_path = os.path.join(TMP, f"dub_tts_{uid_short}.mp3")
1146
 
1147
  t0 = time.time()
1148
  try:
 
1162
 
1163
  elapsed = time.time() - t0
1164
  update(jid, stage="done", done=True, step=4, step_progress=100,
1165
+ message=f"Completato in {elapsed:.2f}s",
1166
  result={
1167
+ "video_url": f"{base_url}/file/{out_mp4_name}",
1168
+ "video_download": f"{base_url}/file/{out_mp4_name}?dl=1",
1169
+ "video_filename": out_mp4_name,
1170
+ "srt_original": f"{base_url}/file/{srt_orig_name}",
1171
+ "srt_translated": f"{base_url}/file/{srt_trans_name}",
1172
  "language": target_lang, "gender": p["gender"],
1173
+ "elapsed_sec": round(elapsed, 2),
1174
  })
1175
  JOBS[jid]["progress"] = 100.0
1176
+ log.info(f"[{jid[:8]}] ✅ done in {elapsed:.2f}s → {out_mp4_name}")
1177
  except InterruptedError:
1178
  log.info(f"[{jid[:8]}] cancelled")
1179
  update(jid, error="Cancellato", done=True)
 
1184
  cleanup_paths([video_path, tts_path])
1185
 
1186
 
1187
+
 
 
1188
  @app.get("/progress/{jid}", tags=["Progress"])
1189
  async def progress(jid: str):
1190
  """Server-Sent Events stream con stato job in real-time."""
 
1200
  ("progress","step","step_progress","message","stage","done","error","result")}
1201
  s = json.dumps(payload)
1202
  now = time.time()
1203
+ # invia sempre se cambia, max 20Hz; heartbeat ogni 1.5s
1204
+ if s != last_send:
1205
  yield f"data: {s}\n\n"
1206
  last_send = s
1207
  last_time = now
1208
+ elif (now - last_time) > 1.5:
1209
+ yield f"data: {s}\n\n"
1210
+ last_time = now
1211
  if j["done"] or j["error"]:
1212
  return
1213
+ await asyncio.sleep(0.05)
1214
  return StreamingResponse(gen(), media_type="text/event-stream",
1215
  headers={"Cache-Control":"no-cache",
1216
  "X-Accel-Buffering":"no",
 
1258
  base = str(request.base_url).rstrip("/")
1259
  asyncio.create_task(run_pipeline(jid, base))
1260
  return {"job_id": jid, "status_url": f"{base}/status/{jid}", "progress_url": f"{base}/progress/{jid}"}
 
 
1261
  @app.post("/job/{jid}/upload", tags=["Jobs"])
1262
  async def job_upload(jid: str, video: UploadFile = File(...)):
1263
  j = JOBS.get(jid)
1264
  if not j: raise HTTPException(404, "job not found")
1265
  if j["video_ready"]: raise HTTPException(400, "video già caricato")
1266
 
1267
+ # Estrai nome originale (senza path/estensione cattiva)
1268
+ original_name = os.path.basename(video.filename or "video.mp4")
1269
+ name_stem = os.path.splitext(original_name)[0]
1270
+ # Sanitize: solo caratteri sicuri per filesystem
1271
+ name_stem = re.sub(r'[^\w\-_.]', '_', name_stem)[:80] or "video"
1272
+ j["original_name"] = name_stem
1273
+ log.info(f"[{jid[:8]}] original filename: '{original_name}' → stem: '{name_stem}'")
1274
+
1275
  uid = uuid.uuid4().hex
1276
  vpath = os.path.join(TMP, f"dub_in_{uid}.mp4")
1277
  size = 0
 
1287
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1288
  raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB")
1289
  now = time.time()
1290
+ if now - last_t > 0.05:
1291
  update(jid, step=0, step_progress=min(95, size/(MAX_UPLOAD_MB*1024*1024)*95),
1292
+ message=f"Ricezione server {size/1048576:.2f} MB")
1293
  last_t = now
1294
 
1295
  if size < MIN_FILE_BYTES:
 
1297
 
1298
  update(jid, step_progress=97, message="Validazione…")
1299
  await validate_video(vpath)
1300
+ log.info(f"[{jid[:8]}] upload OK: {size/1048576:.2f} MB")
1301
+ update(jid, step=0, step_progress=100, message=f"Video pronto ({size/1048576:.2f} MB)",
1302
  video_path=vpath, video_ready=True)
1303
+ return {"ok": True, "size_mb": round(size/1048576, 2), "name": name_stem}
1304
  except HTTPException as e:
1305
  cleanup_paths([vpath])
1306
  update(jid, error=str(e.detail), done=True)
 
1310
  cleanup_paths([vpath])
1311
  update(jid, error=str(e), done=True)
1312
  raise HTTPException(500, str(e))
 
 
1313
  @app.post("/job/{jid}/url", tags=["Jobs"])
1314
  async def job_url(jid: str, video: str = Query(...)):
1315
  j = JOBS.get(jid)
 
1324
  update(jid, error="URL non valido", done=True)
1325
  raise HTTPException(400, "URL deve iniziare con http/https")
1326
 
1327
+ # Estrai nome dall'URL
1328
+ from urllib.parse import urlparse, unquote
1329
+ url_path = unquote(urlparse(video).path)
1330
+ original_name = os.path.basename(url_path) or "video.mp4"
1331
+ name_stem = os.path.splitext(original_name)[0]
1332
+ name_stem = re.sub(r'[^\w\-_.]', '_', name_stem)[:80] or "video"
1333
+ j["original_name"] = name_stem
1334
+ log.info(f"[{jid[:8]}] URL filename: '{original_name}' → stem: '{name_stem}'")
1335
+
1336
  uid = uuid.uuid4().hex
1337
  vpath = os.path.join(TMP, f"dub_in_{uid}.mp4")
1338
 
 
1354
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1355
  raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB")
1356
  now = time.time()
1357
+ if now - last_t > 0.05:
1358
  pct = (size/total*95) if total else min(95, size/(1024*1024))
1359
  update(jid, step_progress=round(pct, 2),
1360
+ message=f"Download {size/1048576:.2f}/{total/1048576:.2f} MB" if total
1361
+ else f"Download {size/1048576:.2f} MB")
1362
  last_t = now
1363
 
1364
  if size < MIN_FILE_BYTES:
1365
  raise HTTPException(400, "File troppo piccolo")
1366
  update(jid, step_progress=97, message="Validazione…")
1367
  await validate_video(vpath)
1368
+ log.info(f"[{jid[:8]}] download OK: {size/1048576:.2f} MB")
1369
+ update(jid, step=0, step_progress=100, message=f"Video pronto ({size/1048576:.2f} MB)",
1370
  video_path=vpath, video_ready=True)
1371
+ return {"ok": True, "size_mb": round(size/1048576, 2), "name": name_stem}
1372
  except HTTPException as e:
1373
  cleanup_paths([vpath])
1374
  update(jid, error=str(e.detail), done=True)
 
1448
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1449
  raise Exception(f"Max {MAX_UPLOAD_MB} MB")
1450
  now = time.time()
1451
+ if now - last_t > 0.05:
1452
  pct = (size/total*95) if total else min(95, size/(1024*1024))
1453
  update(jid, step_progress=round(pct, 2),
1454
  message=f"Download {size/1048576:.1f} MB")
 
1524
  if size > MAX_UPLOAD_MB * 1024 * 1024:
1525
  raise HTTPException(413, f"Max {MAX_UPLOAD_MB} MB")
1526
  now = time.time()
1527
+ if now - last_t > 0.05:
1528
  update(jid, step_progress=min(95, size/(MAX_UPLOAD_MB*1024*1024)*95),
1529
  message=f"Upload {size/1048576:.1f} MB")
1530
  last_t = now
 
1588
 
1589
  @app.get("/health", tags=["Meta"])
1590
  async def health():
1591
+ return {"status":"ok", "version": "2.5.0",
1592
  "jobs_active": sum(1 for j in JOBS.values() if not j["done"]),
1593
  "jobs_total": len(JOBS),
1594
  "max_upload_mb": MAX_UPLOAD_MB}
 
1658
 
1659
  <div class="max-w-5xl mx-auto pt-10 px-6 pb-20">
1660
  <header class="mb-8">
1661
+ <p class="label mb-2">Engine v2.5.0 — Real-time + REST API</p>
1662
  <h1 class="text-4xl md:text-5xl font-semibold tracking-tight">DubFlow <span class="text-slate-400 font-light">Web</span></h1>
1663
  <p class="text-slate-400 mt-2 text-sm">Pipeline: upload · estrazione · traduzione · TTS · dub</p>
1664
  </header>
 
1930
  const elapsed=(Date.now()-uploadStartTime)/1000;
1931
  const speed=e.loaded/elapsed/1048576;
1932
  const eta=e.loaded>0 ? (e.total-e.loaded)/(e.loaded/elapsed) : 0;
1933
+ $('upSpeed').textContent=`${speed.toFixed(2)} MB/s · ETA ${eta.toFixed(1)}s`;
1934
  };
1935
  xhr.upload.onload=()=>{$('upSpeed').textContent='in elaborazione…';};
1936
  xhr.onload=()=>{