mison2264 commited on
Commit
d991e85
·
verified ·
1 Parent(s): 177882b

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +47 -310
index.html CHANGED
@@ -211,29 +211,6 @@ body{
211
  @keyframes rot{to{transform:rotate(360deg);}}
212
  .src-badge.locked{color:#0a8a4a;background:#eefcf3;border-color:#c7f0d7;}
213
 
214
- /* Toggle switch */
215
- .toggle-line{
216
- display:flex;align-items:center;justify-content:space-between;gap:14px;
217
- padding:14px;border-radius:var(--radius-sm);
218
- background:#f7fbff;border:1px solid #e3f0fb;margin:4px 0 18px;
219
- }
220
- .toggle-line .tl-txt{display:flex;flex-direction:column;}
221
- .toggle-line .tl-txt strong{font-size:14px;font-weight:700;}
222
- .toggle-line .tl-txt span{font-size:12px;color:var(--ink-soft);}
223
- .switch{position:relative;width:52px;height:30px;flex-shrink:0;}
224
- .switch input{opacity:0;width:0;height:0;}
225
- .slider{
226
- position:absolute;inset:0;cursor:pointer;border-radius:999px;
227
- background:#cdd9e8;transition:background .25s ease;
228
- }
229
- .slider::before{
230
- content:"";position:absolute;width:22px;height:22px;left:4px;top:4px;
231
- border-radius:50%;background:#fff;transition:transform .25s ease;
232
- box-shadow:0 2px 6px rgba(0,0,0,0.22);
233
- }
234
- .switch input:checked + .slider{background:linear-gradient(120deg,var(--sky),var(--sky-soft));box-shadow:0 0 0 4px rgba(0,162,255,0.15);}
235
- .switch input:checked + .slider::before{transform:translateX(22px);}
236
-
237
  /* Primary button */
238
  .btn{
239
  display:inline-flex;align-items:center;justify-content:center;gap:10px;
@@ -339,76 +316,6 @@ body{
339
  }
340
  .meta-tag svg{color:var(--sky);}
341
 
342
- /* ============================================================
343
- Modal (speaker confirmation)
344
- ============================================================ */
345
- .modal-scrim{
346
- position:fixed;inset:0;z-index:60;display:none;
347
- align-items:center;justify-content:center;padding:22px;
348
- background:rgba(10,25,45,0.42);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
349
- opacity:0;transition:opacity .3s ease;
350
- }
351
- .modal-scrim.show{display:flex;opacity:1;}
352
- .modal{
353
- width:100%;max-width:560px;max-height:88vh;overflow:hidden;
354
- border-radius:24px;
355
- background:var(--panel-glass);
356
- backdrop-filter:blur(26px);-webkit-backdrop-filter:blur(26px);
357
- border:1px solid rgba(255,255,255,0.7);
358
- box-shadow:0 40px 90px -30px rgba(0,80,150,0.55);
359
- transform:translateY(26px) scale(.97);opacity:0;
360
- transition:transform .4s cubic-bezier(.22,.9,.35,1),opacity .35s ease;
361
- display:flex;flex-direction:column;
362
- }
363
- .modal-scrim.show .modal{transform:translateY(0) scale(1);opacity:1;}
364
-
365
- .modal-head{padding:24px 26px 18px;border-bottom:1px solid rgba(0,120,200,0.12);}
366
- .modal-head .mh-top{display:flex;align-items:center;gap:13px;}
367
- .modal-badge{
368
- width:44px;height:44px;border-radius:13px;flex-shrink:0;
369
- display:grid;place-items:center;color:#fff;
370
- background:linear-gradient(135deg,var(--sky),var(--sky-soft));
371
- box-shadow:0 10px 22px -8px rgba(0,162,255,0.7);
372
- }
373
- .modal-head h2{margin:0;font-size:19px;font-weight:800;letter-spacing:-.02em;}
374
- .modal-head .mh-sub{margin:2px 0 0;font-size:13px;color:var(--ink-soft);}
375
- .summary-metric{
376
- display:flex;align-items:center;gap:12px;margin-top:16px;
377
- padding:13px 15px;border-radius:14px;
378
- background:rgba(0,162,255,0.08);border:1px solid rgba(0,162,255,0.18);
379
- }
380
- .summary-metric .sm-ico{color:var(--sky-deep);flex-shrink:0;}
381
- .summary-metric .sm-main{font-weight:750;font-size:15px;}
382
- .summary-metric .sm-sub{font-size:12.5px;color:var(--ink-soft);}
383
-
384
- .modal-body{padding:8px 26px 4px;overflow-y:auto;flex:1;}
385
- .modal-body::-webkit-scrollbar{width:8px;}
386
- .modal-body::-webkit-scrollbar-thumb{background:#cfe0f2;border-radius:8px;}
387
-
388
- .spk-row{
389
- display:flex;align-items:center;gap:14px;padding:14px 2px;
390
- border-bottom:1px solid rgba(0,120,200,0.10);
391
- }
392
- .spk-row:last-child{border-bottom:none;}
393
- .spk-ava{
394
- width:42px;height:42px;border-radius:12px;flex-shrink:0;display:grid;place-items:center;color:#fff;
395
- }
396
- .spk-ava.male{background:linear-gradient(135deg,#2f7fff,#5aa8ff);}
397
- .spk-ava.female{background:linear-gradient(135deg,#ff5fa2,#ff9bc4);}
398
- .spk-ava.unknown{background:linear-gradient(135deg,#8a97ab,#b8c4d6);}
399
- .spk-info{min-width:0;flex:1;}
400
- .spk-info .spk-name{font-weight:700;font-size:14px;}
401
- .spk-info .spk-gender{font-size:12px;color:var(--ink-soft);display:flex;align-items:center;gap:5px;}
402
- .spk-voice{width:150px;flex-shrink:0;}
403
- .spk-voice .control select{padding:10px 34px 10px 12px;font-size:13.5px;}
404
- @media (max-width:480px){
405
- .spk-row{flex-wrap:wrap;}
406
- .spk-voice{width:100%;}
407
- }
408
-
409
- .modal-foot{padding:18px 26px 24px;display:flex;gap:12px;border-top:1px solid rgba(0,120,200,0.12);}
410
- .modal-foot .btn{width:auto;flex:1;padding:14px 18px;}
411
-
412
  .footer{margin-top:44px;text-align:center;color:#9fb0c4;font-size:12.5px;}
413
  .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0;}
414
  </style>
@@ -438,7 +345,7 @@ body{
438
  <!-- ===================== HERO ===================== -->
439
  <section class="hero">
440
  <h1>Dub any video into <span class="grad">any language</span>, flawlessly.</h1>
441
- <p>Upload a clip, pick a target language and voice, and let our neural pipeline transcribe, translate, and re-voice it — with full multi-speaker diarization and real-time progress streaming.</p>
442
  </section>
443
 
444
  <!-- ===================== MAIN GRID ===================== -->
@@ -542,18 +449,6 @@ body{
542
  </span>
543
  </div>
544
 
545
- <!-- Multiple character toggle -->
546
- <div class="toggle-line">
547
- <div class="tl-txt">
548
- <strong>Multiple Characters</strong>
549
- <span>Diarize speakers &amp; assign a voice to each</span>
550
- </div>
551
- <label class="switch" title="Toggle multi-speaker mode">
552
- <input type="checkbox" id="multiToggle" />
553
- <span class="slider"></span>
554
- </label>
555
- </div>
556
-
557
  <button class="btn" id="generateBtn" disabled>
558
  <span class="btn-ico" aria-hidden="true">
559
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
@@ -628,44 +523,6 @@ body{
628
  </div>
629
  </div>
630
 
631
- <!-- ===================== MODAL ===================== -->
632
- <div class="modal-scrim" id="modalScrim" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
633
- <div class="modal">
634
- <div class="modal-head">
635
- <div class="mh-top">
636
- <div class="modal-badge" aria-hidden="true">
637
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
638
- <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
639
- </svg>
640
- </div>
641
- <div>
642
- <h2 id="modalTitle">Assign Speaker Voices</h2>
643
- <p class="mh-sub">We detected multiple speakers. Confirm a voice for each before dubbing.</p>
644
- </div>
645
- </div>
646
- <div class="summary-metric">
647
- <span class="sm-ico" aria-hidden="true">
648
- <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
649
- <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
650
- </svg>
651
- </span>
652
- <div>
653
- <div class="sm-main" id="summaryMain">0 Speakers Detected</div>
654
- <div class="sm-sub" id="summarySub">Source language: —</div>
655
- </div>
656
- </div>
657
- </div>
658
- <div class="modal-body" id="speakerRows"></div>
659
- <div class="modal-foot">
660
- <button class="btn ghost" id="modalCancel">Cancel</button>
661
- <button class="btn" id="applyDubBtn">
662
- <span class="btn-ico" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
663
- <span>Apply &amp; Dub</span>
664
- </button>
665
- </div>
666
- </div>
667
- </div>
668
-
669
  <script>
670
  "use strict";
671
 
@@ -674,26 +531,41 @@ body{
674
  ============================================================ */
675
  const API_BASE = "https://video-dubbing-api-85ur.onrender.com";
676
 
677
- /* Gemini native TTS prebuilt voices, with a coarse gender profile so the
678
- modal can auto-default per the backend's gender tags. */
679
  const VOICES = [
680
- { name: "Kore", profile: "male" },
681
- { name: "Puck", profile: "male" },
682
- { name: "Fenrir", profile: "male" },
683
- { name: "Charon", profile: "male" },
684
- { name: "Orus", profile: "male" },
685
- { name: "Enceladus", profile: "male" },
686
- { name: "Iapetus", profile: "male" },
687
- { name: "Aoede", profile: "female" },
688
- { name: "Leda", profile: "female" },
689
- { name: "Zephyr", profile: "female" },
690
- { name: "Callirrhoe", profile: "female" },
691
- { name: "Autonoe", profile: "female" },
692
- { name: "Despina", profile: "female" },
693
- { name: "Erinome", profile: "female" }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
694
  ];
695
- const DEFAULT_MALE = "Kore";
696
- const DEFAULT_FEMALE = "Aoede";
697
 
698
  /* ============================================================
699
  Element refs
@@ -710,7 +582,6 @@ const fileClear = $("fileClear");
710
 
711
  const targetLang = $("targetLang");
712
  const voiceSelect = $("voiceSelect");
713
- const multiToggle = $("multiToggle");
714
  const generateBtn = $("generateBtn");
715
 
716
  const srcBadge = $("srcBadge");
@@ -732,13 +603,6 @@ const downloadBtn = $("downloadBtn");
732
  const serverDot = $("serverDot");
733
  const serverStatusText = $("serverStatusText");
734
 
735
- const modalScrim = $("modalScrim");
736
- const speakerRows = $("speakerRows");
737
- const summaryMain = $("summaryMain");
738
- const summarySub = $("summarySub");
739
- const modalCancel = $("modalCancel");
740
- const applyDubBtn = $("applyDubBtn");
741
-
742
  /* ============================================================
743
  App state
744
  ============================================================ */
@@ -748,7 +612,6 @@ const state = {
748
  sessionId: null,
749
  abort: null,
750
  targetLanguage: "English",
751
- awaitingConfirm: false,
752
  resultObjectUrl: null,
753
  consoleCleared: false
754
  };
@@ -837,19 +700,10 @@ function buildMainVoiceSelect(){
837
  VOICES.forEach(v=>{
838
  const o = document.createElement("option");
839
  o.value = v.name;
840
- o.textContent = `${v.name} · ${v.profile === "male" ? "Masculine" : "Feminine"}`;
841
  voiceSelect.appendChild(o);
842
  });
843
- voiceSelect.value = DEFAULT_MALE;
844
- }
845
- function voiceOptionsHTML(selected){
846
- return VOICES.map(v=>`<option value="${v.name}" ${v.name===selected?"selected":""}>${v.name} · ${v.profile==="male"?"Masculine":"Feminine"}</option>`).join("");
847
- }
848
- function defaultVoiceForGender(gender){
849
- const g = String(gender||"").toLowerCase();
850
- if(g === "male") return DEFAULT_MALE;
851
- if(g === "female") return DEFAULT_FEMALE;
852
- return voiceSelect.value || DEFAULT_MALE;
853
  }
854
 
855
  /* ============================================================
@@ -866,6 +720,9 @@ function acceptFile(file){
866
  fileChip.classList.add("show");
867
  dropWrap.classList.add("has-file");
868
  refreshGenerateState();
 
 
 
869
  }
870
  function clearFile(){
871
  state.file = null;
@@ -955,7 +812,7 @@ function safeJSON(str){
955
  }
956
 
957
  /* ============================================================
958
- Central SSE event handler (shared by /dub and /confirm-dubbing)
959
  ============================================================ */
960
  function handleSSEEvent(event, data){
961
  switch(event){
@@ -979,11 +836,6 @@ function handleSSEEvent(event, data){
979
  if(!d) break;
980
  if(d.source_language) lockSourceLanguage(d.source_language);
981
  if(d.session_id) state.sessionId = d.session_id;
982
- if(d.status === "awaiting_confirmation"){
983
- pushLog("[SYS] Diarization complete — awaiting voice confirmation.", "l-sys");
984
- pauseAtSixty();
985
- openSpeakerModal(d);
986
- }
987
  break;
988
  }
989
  case "error": {
@@ -1007,7 +859,6 @@ function handleSSEEvent(event, data){
1007
  ============================================================ */
1008
  function startRunUI(){
1009
  state.running = true;
1010
- state.awaitingConfirm = false;
1011
  state.consoleCleared = false;
1012
  generateBtn.classList.add("loading");
1013
  generateBtn.disabled = true;
@@ -1027,13 +878,6 @@ function endRunUI(){
1027
  refreshGenerateState();
1028
  }
1029
 
1030
- function pauseAtSixty(){
1031
- state.awaitingConfirm = true;
1032
- progressFill.classList.add("paused");
1033
- progressFill.classList.remove("active");
1034
- setProgress(60, "Paused — confirm speaker voices");
1035
- }
1036
-
1037
  function failRun(reason){
1038
  pushLog("[WARN] " + (reason || "Run stopped.") + " State reset.", "l-warn");
1039
  if(state.abort){ try{ state.abort.abort(); }catch(_){}}
@@ -1093,78 +937,7 @@ async function presentResult(sessionId, meta){
1093
  }
1094
 
1095
  /* ============================================================
1096
- Speaker confirmation modal
1097
- ============================================================ */
1098
- function openSpeakerModal(payload){
1099
- const speakers = Array.isArray(payload.speakers) ? payload.speakers : [];
1100
- const male = speakers.filter(s=>String(s.gender).toLowerCase()==="male").length;
1101
- const female = speakers.filter(s=>String(s.gender).toLowerCase()==="female").length;
1102
- const unknown = speakers.length - male - female;
1103
-
1104
- const parts = [];
1105
- if(male) parts.push(`${male} Male`);
1106
- if(female) parts.push(`${female} Female`);
1107
- if(unknown) parts.push(`${unknown} Unknown`);
1108
- summaryMain.textContent = `${speakers.length} Speaker${speakers.length===1?"":"s"} Detected`;
1109
- summarySub.textContent = (parts.length ? "(" + parts.join(", ") + ")" : "") +
1110
- (payload.source_language ? ` · Source: ${payload.source_language}` : "");
1111
-
1112
- // Build rows
1113
- speakerRows.innerHTML = "";
1114
- speakers.forEach((sp, i)=>{
1115
- const id = sp.id || `Speaker_${i}`;
1116
- const gender = (sp.gender || "Unknown");
1117
- const g = gender.toLowerCase();
1118
- const avaClass = g==="male" ? "male" : g==="female" ? "female" : "unknown";
1119
- const def = defaultVoiceForGender(gender);
1120
-
1121
- const row = document.createElement("div");
1122
- row.className = "spk-row";
1123
- row.innerHTML = `
1124
- <div class="spk-ava ${avaClass}" aria-hidden="true">
1125
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
1126
- </div>
1127
- <div class="spk-info">
1128
- <div class="spk-name">${esc(id)}</div>
1129
- <div class="spk-gender">${esc(gender)} voice profile</div>
1130
- </div>
1131
- <div class="spk-voice">
1132
- <div class="control">
1133
- <select data-speaker="${esc(id)}">${voiceOptionsHTML(def)}</select>
1134
- <span class="chev" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>
1135
- </div>
1136
- </div>`;
1137
- speakerRows.appendChild(row);
1138
- });
1139
-
1140
- modalScrim.classList.add("show");
1141
- }
1142
-
1143
- function closeModal(){ modalScrim.classList.remove("show"); }
1144
-
1145
- modalCancel.addEventListener("click", ()=>{
1146
- closeModal();
1147
- pushLog("[WARN] Voice confirmation cancelled. Aborting session.", "l-warn");
1148
- if(state.sessionId){
1149
- fetch(`${API_BASE}/session/${encodeURIComponent(state.sessionId)}`, { method:"DELETE" }).catch(()=>{});
1150
- }
1151
- if(state.abort){ try{ state.abort.abort(); }catch(_){}}
1152
- endRunUI();
1153
- setProgress(0, "Cancelled");
1154
- });
1155
-
1156
- modalScrim.addEventListener("mousedown",(e)=>{ if(e.target === modalScrim) modalCancel.click(); });
1157
-
1158
- applyDubBtn.addEventListener("click", async ()=>{
1159
- const selects = speakerRows.querySelectorAll("select[data-speaker]");
1160
- const voiceMap = {};
1161
- selects.forEach(s=>{ voiceMap[s.getAttribute("data-speaker")] = s.value; });
1162
- closeModal();
1163
- await confirmDubbing(voiceMap);
1164
- });
1165
-
1166
- /* ============================================================
1167
- Network: /dub and /confirm-dubbing
1168
  ============================================================ */
1169
  async function startDub(){
1170
  if(!state.file || state.running) return;
@@ -1172,20 +945,19 @@ async function startDub(){
1172
  state.targetLanguage = targetLang.value;
1173
  startRunUI();
1174
  pushLog(`[SYS] Uploading "${state.file.name}" (${fmtBytes(state.file.size)})…`, "l-sys");
1175
- pushLog(`[INFO] Target language: ${targetLang.value} · Voice: ${voiceSelect.value} · Multi-speaker: ${multiToggle.checked ? "ON" : "OFF"}`);
1176
 
1177
  const fd = new FormData();
1178
  fd.append("file", state.file);
1179
  fd.append("target_language", targetLang.value);
1180
  fd.append("voice_name", voiceSelect.value);
1181
- fd.append("multiple_characters", multiToggle.checked ? "true" : "false");
1182
 
1183
  state.abort = new AbortController();
1184
  try{
1185
  const res = await fetch(`${API_BASE}/dub`, { method:"POST", body:fd, signal:state.abort.signal });
1186
  await readSSE(res, handleSSEEvent);
1187
- // Stream ended. If we were mid-run without a done/error/confirm, note it.
1188
- if(state.running && !state.awaitingConfirm){
1189
  pushLog("[WARN] Stream closed unexpectedly before completion.", "l-warn");
1190
  endRunUI();
1191
  }
@@ -1198,42 +970,6 @@ async function startDub(){
1198
  }
1199
  }
1200
 
1201
- async function confirmDubbing(voiceMap){
1202
- if(!state.sessionId){
1203
- pushLog("[ERROR] Missing session id — cannot confirm voices.", "l-err");
1204
- failRun("No active session.");
1205
- return;
1206
- }
1207
- state.awaitingConfirm = false;
1208
- state.running = true;
1209
- generateBtn.classList.add("loading");
1210
- progressFill.classList.remove("paused");
1211
- progressFill.classList.add("active");
1212
- setLive(true);
1213
- setProgress(61, "Resuming with confirmed voices…");
1214
- pushLog("[SYS] Voice mappings: " + JSON.stringify(voiceMap), "l-sys");
1215
-
1216
- const fd = new FormData();
1217
- fd.append("session_id", state.sessionId);
1218
- fd.append("voice_mappings", JSON.stringify(voiceMap));
1219
-
1220
- state.abort = new AbortController();
1221
- try{
1222
- const res = await fetch(`${API_BASE}/confirm-dubbing`, { method:"POST", body:fd, signal:state.abort.signal });
1223
- await readSSE(res, handleSSEEvent);
1224
- if(state.running){
1225
- pushLog("[WARN] Confirmation stream closed before completion.", "l-warn");
1226
- endRunUI();
1227
- }
1228
- }catch(err){
1229
- if(err.name === "AbortError"){ /* cancelled */ }
1230
- else{
1231
- pushLog("[ERROR] Confirmation failed: " + err.message, "l-err");
1232
- failRun("Confirm-dubbing stream error.");
1233
- }
1234
- }
1235
- }
1236
-
1237
  generateBtn.addEventListener("click", startDub);
1238
 
1239
  /* ============================================================
@@ -1244,8 +980,9 @@ async function checkHealth(){
1244
  const res = await fetch(`${API_BASE}/health`, { method:"GET" });
1245
  const j = await res.json();
1246
  if(j.status === "ok"){
1247
- serverDot.style.background = "var(--ok)";
1248
- serverStatusText.textContent = j.gemini_key_configured ? "Backend online" : "Online · key missing";
 
1249
  }else{
1250
  serverDot.style.background = "var(--warn)";
1251
  serverStatusText.textContent = "Backend degraded";
 
211
  @keyframes rot{to{transform:rotate(360deg);}}
212
  .src-badge.locked{color:#0a8a4a;background:#eefcf3;border-color:#c7f0d7;}
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  /* Primary button */
215
  .btn{
216
  display:inline-flex;align-items:center;justify-content:center;gap:10px;
 
316
  }
317
  .meta-tag svg{color:var(--sky);}
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  .footer{margin-top:44px;text-align:center;color:#9fb0c4;font-size:12.5px;}
320
  .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0;}
321
  </style>
 
345
  <!-- ===================== HERO ===================== -->
346
  <section class="hero">
347
  <h1>Dub any video into <span class="grad">any language</span>, flawlessly.</h1>
348
+ <p>Pick a video from your gallery — upload starts instantly. Our pipeline transcribes, translates, and re-voices it in one Gemini request, then matches the audio to your video's exact length with real-time progress streaming.</p>
349
  </section>
350
 
351
  <!-- ===================== MAIN GRID ===================== -->
 
449
  </span>
450
  </div>
451
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  <button class="btn" id="generateBtn" disabled>
453
  <span class="btn-ico" aria-hidden="true">
454
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
 
523
  </div>
524
  </div>
525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  <script>
527
  "use strict";
528
 
 
531
  ============================================================ */
532
  const API_BASE = "https://video-dubbing-api-85ur.onrender.com";
533
 
534
+ /* All 30 official Gemini native-TTS prebuilt voices, with Google's own
535
+ one-word style tag for each (ai.google.dev/gemini-api/docs/speech-generation). */
536
  const VOICES = [
537
+ { name: "Zephyr", style: "Bright" },
538
+ { name: "Puck", style: "Upbeat" },
539
+ { name: "Charon", style: "Informative" },
540
+ { name: "Kore", style: "Firm" },
541
+ { name: "Fenrir", style: "Excitable" },
542
+ { name: "Leda", style: "Youthful" },
543
+ { name: "Orus", style: "Firm" },
544
+ { name: "Aoede", style: "Breezy" },
545
+ { name: "Callirrhoe", style: "Easy-going" },
546
+ { name: "Autonoe", style: "Bright" },
547
+ { name: "Enceladus", style: "Breathy" },
548
+ { name: "Iapetus", style: "Clear" },
549
+ { name: "Umbriel", style: "Easy-going" },
550
+ { name: "Algieba", style: "Smooth" },
551
+ { name: "Despina", style: "Smooth" },
552
+ { name: "Erinome", style: "Clear" },
553
+ { name: "Algenib", style: "Gravelly" },
554
+ { name: "Rasalgethi", style: "Informative" },
555
+ { name: "Laomedeia", style: "Upbeat" },
556
+ { name: "Achernar", style: "Soft" },
557
+ { name: "Alnilam", style: "Firm" },
558
+ { name: "Schedar", style: "Even" },
559
+ { name: "Gacrux", style: "Mature" },
560
+ { name: "Pulcherrima", style: "Forward" },
561
+ { name: "Achird", style: "Friendly" },
562
+ { name: "Zubenelgenubi", style: "Casual" },
563
+ { name: "Vindemiatrix", style: "Gentle" },
564
+ { name: "Sadachbia", style: "Lively" },
565
+ { name: "Sadaltager", style: "Knowledgeable" },
566
+ { name: "Sulafat", style: "Warm" }
567
  ];
568
+ const DEFAULT_VOICE = "Kore";
 
569
 
570
  /* ============================================================
571
  Element refs
 
582
 
583
  const targetLang = $("targetLang");
584
  const voiceSelect = $("voiceSelect");
 
585
  const generateBtn = $("generateBtn");
586
 
587
  const srcBadge = $("srcBadge");
 
603
  const serverDot = $("serverDot");
604
  const serverStatusText = $("serverStatusText");
605
 
 
 
 
 
 
 
 
606
  /* ============================================================
607
  App state
608
  ============================================================ */
 
612
  sessionId: null,
613
  abort: null,
614
  targetLanguage: "English",
 
615
  resultObjectUrl: null,
616
  consoleCleared: false
617
  };
 
700
  VOICES.forEach(v=>{
701
  const o = document.createElement("option");
702
  o.value = v.name;
703
+ o.textContent = `${v.name} · ${v.style}`;
704
  voiceSelect.appendChild(o);
705
  });
706
+ voiceSelect.value = DEFAULT_VOICE;
 
 
 
 
 
 
 
 
 
707
  }
708
 
709
  /* ============================================================
 
720
  fileChip.classList.add("show");
721
  dropWrap.classList.add("has-file");
722
  refreshGenerateState();
723
+ // Upload starts the instant a video is picked (from the gallery, a file
724
+ // browser, or drag-and-drop) — no extra tap needed.
725
+ if(!state.running) startDub();
726
  }
727
  function clearFile(){
728
  state.file = null;
 
812
  }
813
 
814
  /* ============================================================
815
+ Central SSE event handler for the /dub stream
816
  ============================================================ */
817
  function handleSSEEvent(event, data){
818
  switch(event){
 
836
  if(!d) break;
837
  if(d.source_language) lockSourceLanguage(d.source_language);
838
  if(d.session_id) state.sessionId = d.session_id;
 
 
 
 
 
839
  break;
840
  }
841
  case "error": {
 
859
  ============================================================ */
860
  function startRunUI(){
861
  state.running = true;
 
862
  state.consoleCleared = false;
863
  generateBtn.classList.add("loading");
864
  generateBtn.disabled = true;
 
878
  refreshGenerateState();
879
  }
880
 
 
 
 
 
 
 
 
881
  function failRun(reason){
882
  pushLog("[WARN] " + (reason || "Run stopped.") + " State reset.", "l-warn");
883
  if(state.abort){ try{ state.abort.abort(); }catch(_){}}
 
937
  }
938
 
939
  /* ============================================================
940
+ Network: /dub
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
941
  ============================================================ */
942
  async function startDub(){
943
  if(!state.file || state.running) return;
 
945
  state.targetLanguage = targetLang.value;
946
  startRunUI();
947
  pushLog(`[SYS] Uploading "${state.file.name}" (${fmtBytes(state.file.size)})…`, "l-sys");
948
+ pushLog(`[INFO] Target language: ${targetLang.value} · Voice: ${voiceSelect.value}`);
949
 
950
  const fd = new FormData();
951
  fd.append("file", state.file);
952
  fd.append("target_language", targetLang.value);
953
  fd.append("voice_name", voiceSelect.value);
 
954
 
955
  state.abort = new AbortController();
956
  try{
957
  const res = await fetch(`${API_BASE}/dub`, { method:"POST", body:fd, signal:state.abort.signal });
958
  await readSSE(res, handleSSEEvent);
959
+ // Stream ended. If we were mid-run without a done/error, note it.
960
+ if(state.running){
961
  pushLog("[WARN] Stream closed unexpectedly before completion.", "l-warn");
962
  endRunUI();
963
  }
 
970
  }
971
  }
972
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
973
  generateBtn.addEventListener("click", startDub);
974
 
975
  /* ============================================================
 
980
  const res = await fetch(`${API_BASE}/health`, { method:"GET" });
981
  const j = await res.json();
982
  if(j.status === "ok"){
983
+ const hasKeys = (j.gemini_keys_configured|0) > 0 && (j.groq_keys_configured|0) > 0;
984
+ serverDot.style.background = hasKeys ? "var(--ok)" : "var(--warn)";
985
+ serverStatusText.textContent = hasKeys ? "Backend online" : "Online · API key missing";
986
  }else{
987
  serverDot.style.background = "var(--warn)";
988
  serverStatusText.textContent = "Backend degraded";