Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Video Dubbing Agent</title> | |
| <style> | |
| :root { | |
| --bg: #0a0a12; --s1: #13132a; --s2: #1c1c3a; --border: #2a2a4a; | |
| --accent: #7c5cfc; --accent2: #a78bfa; --green: #10b981; | |
| --yellow: #f59e0b; --red: #ef4444; --text: #e8e8f4; --text2: #8888a8; | |
| } | |
| * { margin:0; padding:0; box-sizing:border-box; } | |
| body { font-family:'Inter','Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; } | |
| .wrap { max-width:860px; margin:0 auto; padding:1.5rem; } | |
| /* Header */ | |
| .hdr { text-align:center; padding:2rem 0 1.5rem; } | |
| .hdr h1 { font-size:2rem; background:linear-gradient(135deg,var(--accent),var(--green)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; } | |
| .hdr p { color:var(--text2); font-size:0.9rem; margin-top:0.3rem; } | |
| .badge { display:inline-block; padding:0.2rem 0.6rem; background:rgba(124,92,252,0.15); color:var(--accent2); border-radius:20px; font-size:0.7rem; font-weight:600; margin-top:0.5rem; border:1px solid rgba(124,92,252,0.3); } | |
| /* Card */ | |
| .card { background:var(--s1); border:1px solid var(--border); border-radius:14px; padding:1.5rem; margin-bottom:1.2rem; } | |
| .card h2 { font-size:1rem; color:var(--accent2); margin-bottom:1rem; } | |
| /* Form */ | |
| .fg { margin-bottom:0.9rem; } | |
| .fg label { display:block; font-size:0.78rem; color:var(--text2); margin-bottom:0.3rem; font-weight:500; text-transform:uppercase; letter-spacing:0.5px; } | |
| input, select { width:100%; padding:0.7rem 0.9rem; background:var(--s2); border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:0.9rem; outline:none; transition:border 0.2s; } | |
| input:focus, select:focus { border-color:var(--accent); } | |
| select option { background:var(--s2); } | |
| /* Button */ | |
| .btn { display:flex; align-items:center; justify-content:center; gap:0.5rem; padding:0.8rem; background:linear-gradient(135deg,var(--accent),#6d28d9); color:#fff; border:none; border-radius:10px; font-size:0.95rem; font-weight:600; cursor:pointer; width:100%; transition:all 0.2s; } | |
| .btn:hover { opacity:0.9; transform:translateY(-1px); } | |
| .btn:disabled { opacity:0.4; cursor:not-allowed; transform:none; } | |
| .btn-green { background:linear-gradient(135deg,var(--green),#059669); } | |
| .btn-sm { padding:0.5rem 1rem; font-size:0.8rem; width:auto; border-radius:8px; } | |
| .btn-ghost { background:var(--s2); border:1px solid var(--border); } | |
| /* Progress */ | |
| .hidden { display:none !important; } | |
| .prog-bar-wrap { background:var(--s2); border-radius:20px; height:32px; position:relative; overflow:hidden; margin:0.8rem 0; } | |
| .prog-bar { height:100%; background:linear-gradient(90deg,var(--accent),var(--green)); border-radius:20px; transition:width 0.6s ease; } | |
| .prog-text { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:0.8rem; font-weight:700; color:#fff; text-shadow:0 1px 3px rgba(0,0,0,0.5); } | |
| /* Stats grid */ | |
| .stats { display:grid; grid-template-columns:1fr 1fr 1fr; gap:0.6rem; margin:0.8rem 0; } | |
| .stat { background:var(--s2); padding:0.6rem 0.8rem; border-radius:8px; } | |
| .stat .lbl { font-size:0.65rem; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; } | |
| .stat .val { font-size:1rem; font-weight:700; margin-top:0.15rem; } | |
| /* Pipeline visual */ | |
| .pipe { display:flex; flex-wrap:wrap; gap:0.3rem; margin:0.8rem 0; } | |
| .dot { padding:0.25rem 0.55rem; border-radius:6px; font-size:0.65rem; font-weight:500; background:var(--s2); color:var(--text2); border:1px solid var(--border); transition:all 0.3s; } | |
| .dot.done { background:rgba(16,185,129,0.12); color:var(--green); border-color:var(--green); } | |
| .dot.now { background:rgba(124,92,252,0.15); color:var(--accent2); border-color:var(--accent); animation:blink 1.2s ease-in-out infinite; } | |
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.5} } | |
| /* Log */ | |
| .log { background:#06060e; border:1px solid var(--border); border-radius:8px; padding:0.8rem; font-family:'Fira Code',monospace; font-size:0.72rem; color:var(--text2); max-height:180px; overflow-y:auto; margin-top:0.8rem; } | |
| .log .l { padding:0.1rem 0; } | |
| .log .ok { color:var(--green); } | |
| .log .wr { color:var(--yellow); } | |
| .log .er { color:var(--red); } | |
| /* Result */ | |
| .res-ok { border-color:var(--green) !important; } | |
| .res-err { border-color:var(--red) !important; } | |
| /* Jobs */ | |
| .job { display:flex; justify-content:space-between; align-items:center; padding:0.6rem 0.8rem; background:var(--s2); border-radius:8px; margin-bottom:0.4rem; } | |
| .job-id { font-family:monospace; color:var(--accent2); font-size:0.85rem; } | |
| .job-st { padding:0.15rem 0.5rem; border-radius:4px; font-size:0.7rem; font-weight:600; } | |
| .st-completed { background:rgba(16,185,129,0.12); color:var(--green); } | |
| .st-processing { background:rgba(124,92,252,0.12); color:var(--accent2); } | |
| .st-failed { background:rgba(239,68,68,0.12); color:var(--red); } | |
| .st-queued { background:rgba(245,158,11,0.12); color:var(--yellow); } | |
| .dl-row { display:flex; gap:0.5rem; margin-top:0.6rem; flex-wrap:wrap; } | |
| .footer { text-align:center; padding:1.5rem 0; color:var(--text2); font-size:0.72rem; } | |
| @media(max-width:600px) { .stats{grid-template-columns:1fr 1fr} .wrap{padding:1rem} .hdr h1{font-size:1.5rem} } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="wrap"> | |
| <div class="hdr"> | |
| <h1>Video Dubbing Agent</h1> | |
| <p>YouTube video → dubbed in any language with AI</p> | |
| <span class="badge">MALE VOICE • FREE GPU • 20+ LANGUAGES</span> | |
| </div> | |
| <!-- INPUT --> | |
| <div class="card" id="inputCard"> | |
| <h2>New Dubbing Job</h2> | |
| <!-- Tab switcher --> | |
| <div style="display:flex;gap:0.4rem;margin-bottom:1rem;"> | |
| <button class="btn btn-sm" id="tabYt" onclick="switchTab('yt')" style="flex:1;background:var(--accent)">YouTube URL</button> | |
| <button class="btn btn-sm" id="tabUp" onclick="switchTab('up')" style="flex:1;background:var(--s2);border:1px solid var(--border)">Upload Video</button> | |
| </div> | |
| <!-- YouTube input --> | |
| <div id="ytInput"> | |
| <div class="fg"> | |
| <label>YouTube URL</label> | |
| <input type="text" id="ytUrl" placeholder="https://www.youtube.com/watch?v=..." value="https://www.youtube.com/watch?v=LiQ91h3yFeI" /> | |
| </div> | |
| </div> | |
| <!-- Upload input --> | |
| <div id="upInput" class="hidden"> | |
| <div class="fg"> | |
| <label>Upload Video File (MP4, MKV, AVI)</label> | |
| <input type="file" id="fileInput" accept="video/*" style="padding:0.5rem;background:var(--s2);border:1px solid var(--border);border-radius:8px;width:100%;color:var(--text);" /> | |
| </div> | |
| <div id="fileInfo" style="font-size:0.8rem;color:var(--text2);margin-bottom:0.5rem;"></div> | |
| </div> | |
| <div class="fg"> | |
| <label>Target Language</label> | |
| <select id="lang"> | |
| <option value="mr" selected>Marathi</option> | |
| <option value="hi">Hindi</option> | |
| <option value="en">English</option> | |
| <option value="es">Spanish</option> | |
| <option value="fr">French</option> | |
| <option value="de">German</option> | |
| <option value="ja">Japanese</option> | |
| <option value="zh">Chinese</option> | |
| <option value="pt">Portuguese</option> | |
| <option value="ar">Arabic</option> | |
| <option value="ko">Korean</option> | |
| <option value="ru">Russian</option> | |
| <option value="it">Italian</option> | |
| <option value="ta">Tamil</option> | |
| <option value="te">Telugu</option> | |
| <option value="bn">Bengali</option> | |
| <option value="gu">Gujarati</option> | |
| <option value="kn">Kannada</option> | |
| <option value="ml">Malayalam</option> | |
| <option value="ur">Urdu</option> | |
| </select> | |
| </div> | |
| <button class="btn" id="startBtn" onclick="startJob()">Start Dubbing</button> | |
| </div> | |
| <!-- PROGRESS --> | |
| <div class="card hidden" id="progCard"> | |
| <h2 id="progTitle">Processing...</h2> | |
| <div id="vidTitle" style="color:var(--text2);font-size:0.85rem;margin-bottom:0.4rem"></div> | |
| <div class="prog-bar-wrap"> | |
| <div class="prog-bar" id="pBar" style="width:0%"></div> | |
| <div class="prog-text" id="pText">0%</div> | |
| </div> | |
| <div class="stats"> | |
| <div class="stat"><div class="lbl">Stage</div><div class="val" id="sStage">Queued</div></div> | |
| <div class="stat"><div class="lbl">Progress</div><div class="val" id="sNum">0 / 11</div></div> | |
| <div class="stat"><div class="lbl">Elapsed</div><div class="val" id="sTime">0s</div></div> | |
| </div> | |
| <div class="pipe" id="pipeDots"></div> | |
| <div class="log" id="logBox"></div> | |
| </div> | |
| <!-- RESULT --> | |
| <div class="card hidden" id="resCard"> | |
| <h2 id="resTitle">Done!</h2> | |
| <p id="resMsg" style="margin-bottom:0.8rem"></p> | |
| <button class="btn btn-green" id="dlBtn" onclick="dlVideo()">Download Dubbed Video (.mp4)</button> | |
| <div class="dl-row"> | |
| <button class="btn btn-sm btn-ghost" onclick="dlSub('original')">Original Subtitles</button> | |
| <button class="btn btn-sm btn-ghost" onclick="dlSub('translated')">Translated Subtitles</button> | |
| <button class="btn btn-sm btn-ghost" onclick="resetUI()">New Job</button> | |
| </div> | |
| </div> | |
| <!-- JOBS --> | |
| <div class="card"> | |
| <h2>Recent Jobs</h2> | |
| <div id="jobsList"><span style="color:var(--text2);font-size:0.85rem">No jobs yet</span></div> | |
| </div> | |
| <div class="footer">Video Dubbing Agent v2.0 — HuggingFace GPU + Edge TTS + FFmpeg • Male Voice Only • 100% Free</div> | |
| </div> | |
| <script> | |
| const API='/api'; | |
| let jobId=null, poll=null, mode='yt'; | |
| const SL={downloading:'Download',extracting_audio:'Extract Audio',separating_vocals:'Vocal Split',transcribing:'Transcribe (GPU)',profiling_speakers:'Speakers',translating:'Translate',generating_tts:'TTS (Male)',assembling_audio:'Assembly',mixing_audio:'Mixing',merging_video:'Merge Video',generating_subtitles:'Subtitles'}; | |
| function switchTab(m){ | |
| mode=m; | |
| document.getElementById('ytInput').classList.toggle('hidden',m!=='yt'); | |
| document.getElementById('upInput').classList.toggle('hidden',m!=='up'); | |
| document.getElementById('tabYt').style.background=m==='yt'?'var(--accent)':'var(--s2)'; | |
| document.getElementById('tabUp').style.background=m==='up'?'var(--accent)':'var(--s2)'; | |
| } | |
| document.getElementById('fileInput')?.addEventListener('change',function(e){ | |
| const f=e.target.files[0]; | |
| if(f)document.getElementById('fileInfo').textContent=`${f.name} (${(f.size/1e6).toFixed(1)} MB)`; | |
| }); | |
| function dots(cur){ | |
| const c=document.getElementById('pipeDots'); | |
| const st=Object.keys(SL), ci=st.indexOf(cur); | |
| c.innerHTML=st.map((s,i)=>`<span class="dot${i<ci?' done':i===ci?' now':''}">${SL[s]}</span>`).join(''); | |
| } | |
| function log(m,t=''){ | |
| const b=document.getElementById('logBox'),d=document.createElement('div'); | |
| d.className=`l ${t}`;d.textContent=`[${new Date().toLocaleTimeString()}] ${m}`; | |
| b.appendChild(d);b.scrollTop=b.scrollHeight; | |
| } | |
| async function startJob(){ | |
| const btn=document.getElementById('startBtn'); | |
| btn.disabled=true;btn.textContent='Starting...'; | |
| try{ | |
| let r; | |
| if(mode==='yt'){ | |
| // YouTube URL mode | |
| const url=document.getElementById('ytUrl').value.trim(); | |
| if(!url){alert('Enter a YouTube URL');btn.disabled=false;btn.textContent='Start Dubbing';return} | |
| r=await fetch(`${API}/dub`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({youtube_url:url,target_language:document.getElementById('lang').value,preserve_background:true,generate_subtitles:true})}); | |
| } else { | |
| // File upload mode | |
| const fileEl=document.getElementById('fileInput'); | |
| if(!fileEl.files[0]){alert('Select a video file');btn.disabled=false;btn.textContent='Start Dubbing';return} | |
| log('Uploading video file...','ok'); | |
| const fd=new FormData(); | |
| fd.append('file',fileEl.files[0]); | |
| fd.append('target_language',document.getElementById('lang').value); | |
| fd.append('preserve_background','true'); | |
| fd.append('generate_subtitles','true'); | |
| r=await fetch(`${API}/dub-upload`,{method:'POST',body:fd}); | |
| } | |
| const d=await r.json(); | |
| if(!r.ok)throw new Error(d.detail||'Failed'); | |
| jobId=d.job_id;log(`Job ${jobId} started`,'ok'); | |
| document.getElementById('progCard').classList.remove('hidden'); | |
| document.getElementById('resCard').classList.add('hidden'); | |
| poll=setInterval(pollJob,2500); | |
| }catch(e){alert(e.message);log(e.message,'er')} | |
| finally{btn.disabled=false;btn.textContent='Start Dubbing'} | |
| } | |
| async function pollJob(){ | |
| if(!jobId)return; | |
| try{ | |
| const r=await fetch(`${API}/status/${jobId}`); | |
| const d=await r.json(); | |
| document.getElementById('sStage').textContent=SL[d.current_stage]||d.current_stage||'Queued'; | |
| document.getElementById('sNum').textContent=`${d.stage_number} / ${d.total_stages}`; | |
| document.getElementById('sTime').textContent=d.elapsed_time_str||'0s'; | |
| document.getElementById('pBar').style.width=`${d.progress_percent}%`; | |
| document.getElementById('pText').textContent=`${d.progress_percent}%`; | |
| if(d.video_title)document.getElementById('vidTitle').textContent=d.video_title; | |
| dots(d.current_stage); | |
| if(d.status==='completed'){clearInterval(poll);log('DONE!','ok');showRes(true,'Your dubbed video is ready!')} | |
| else if(d.status==='failed'){clearInterval(poll);log(`FAILED: ${d.error_message}`,'er');showRes(false,d.error_message)} | |
| else log(`${SL[d.current_stage]||d.current_stage} — ${d.progress_percent}%`); | |
| refreshJobs(); | |
| }catch(e){log(`Poll err: ${e.message}`,'wr')} | |
| } | |
| function showRes(ok,msg){ | |
| const c=document.getElementById('resCard'); | |
| c.classList.remove('hidden','res-ok','res-err'); | |
| c.classList.add(ok?'res-ok':'res-err'); | |
| document.getElementById('resTitle').textContent=ok?'Dubbing Complete!':'Failed'; | |
| document.getElementById('resMsg').textContent=msg; | |
| document.getElementById('dlBtn').style.display=ok?'flex':'none'; | |
| } | |
| function dlVideo(){if(jobId)window.open(`${API}/download/${jobId}`,'_blank')} | |
| function dlSub(t){if(jobId)window.open(`${API}/download/${jobId}/subtitles/${t}`,'_blank')} | |
| function resetUI(){ | |
| jobId=null; | |
| document.getElementById('progCard').classList.add('hidden'); | |
| document.getElementById('resCard').classList.add('hidden'); | |
| document.getElementById('logBox').innerHTML=''; | |
| document.getElementById('pBar').style.width='0%'; | |
| document.getElementById('pText').textContent='0%'; | |
| } | |
| async function refreshJobs(){ | |
| try{ | |
| const r=await fetch(`${API}/jobs`),d=await r.json(),c=document.getElementById('jobsList'); | |
| if(!d.jobs||!d.jobs.length){c.innerHTML='<span style="color:var(--text2);font-size:0.85rem">No jobs yet</span>';return} | |
| c.innerHTML=d.jobs.slice(-5).reverse().map(j=>`<div class="job"><div><span class="job-id">${j.job_id}</span> <span style="color:var(--text2);font-size:0.75rem">${j.video_title||''}</span></div><span class="job-st st-${j.status}">${j.status}</span></div>`).join(''); | |
| }catch(e){} | |
| } | |
| refreshJobs(); | |
| </script> | |
| </body> | |
| </html> | |