Spaces:
Sleeping
Sleeping
CreatorKit Claude Opus 4.8 commited on
Commit ·
2380c62
1
Parent(s): 8661b79
fix: evita travamento no Estudio de Video
Browse files- Timeouts no download (3min) e na transcricao (10min): jobs nunca
ficam presos para sempre; falham com mensagem clara.
- yt-dlp agora roda via spawn com kill garantido e flags de falha
rapida (socket-timeout, retries limitados).
- Erros do yt-dlp traduzidos para PT (login necessario, privado,
indisponivel, etc.) em vez de spinner infinito.
- Modelo padrao alterado de "small" para "tiny": muito mais rapido
no CPU, evitando a sensacao de travado. Labels indicam velocidade.
- Aviso na UI durante o processamento explicando o tempo de espera.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- server.js +51 -16
- templates/index.html +8 -6
server.js
CHANGED
|
@@ -18,6 +18,8 @@ const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
|
| 18 |
const YTDLP_PATH = process.env.YTDLP_PATH ||
|
| 19 |
path.join(__dirname, 'bin', IS_WIN ? 'yt-dlp.exe' : 'yt-dlp');
|
| 20 |
const JOB_TTL_MS = 60 * 60 * 1000; // 1h: jobs e arquivos temporários expiram
|
|
|
|
|
|
|
| 21 |
|
| 22 |
[DOWNLOADS_DIR, UPLOADS_DIR, path.join(__dirname, 'bin')].forEach(d => fs.mkdirSync(d, { recursive: true }));
|
| 23 |
|
|
@@ -94,9 +96,43 @@ async function ensureYtDlp() {
|
|
| 94 |
return ytdlpReady;
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
async function getVideoInfo(url) {
|
| 98 |
-
const
|
| 99 |
-
const raw = await ytdlp.execPromise([url, '--dump-json', '--no-playlist', '--quiet']);
|
| 100 |
const info = JSON.parse(raw);
|
| 101 |
return {
|
| 102 |
title: info.title || '',
|
|
@@ -124,10 +160,9 @@ function convertToMp3(inputFile, outMp3, deleteInput = false, hq = false) {
|
|
| 124 |
async function downloadAudioFromUrl(jobId, url, setStatus = () => {}) {
|
| 125 |
const outMp3 = path.join(DOWNLOADS_DIR, `${jobId}.mp3`);
|
| 126 |
const tmpOut = path.join(DOWNLOADS_DIR, `${jobId}.%(ext)s`);
|
| 127 |
-
|
| 128 |
-
await ytdlp.execPromise([url, '--format', 'bestaudio/best', '--output', tmpOut, '--no-playlist', '--quiet']);
|
| 129 |
const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(jobId) && !f.endsWith('.mp3'));
|
| 130 |
-
if (!files.length) throw new Error('Não foi possível baixar o
|
| 131 |
setStatus('extracting');
|
| 132 |
await convertToMp3(path.join(DOWNLOADS_DIR, files[0]), outMp3, true);
|
| 133 |
return outMp3;
|
|
@@ -138,16 +173,18 @@ function transcribeAudio(mp3Path, modelName, language, task = 'transcribe') {
|
|
| 138 |
return new Promise((resolve, reject) => {
|
| 139 |
const worker = path.join(__dirname, 'whisper_worker.js');
|
| 140 |
const child = spawn(process.execPath, [worker, mp3Path, modelName, language, task], { stdio: ['ignore', 'pipe', 'pipe'] });
|
| 141 |
-
let stdout = '', stderr = '', settled = false;
|
|
|
|
| 142 |
child.stdout.on('data', d => { stdout += d.toString(); });
|
| 143 |
child.stderr.on('data', d => { const l = d.toString().trim(); if (l) { stderr += l + '\n'; console.log('[worker]', l); } });
|
| 144 |
child.on('close', code => {
|
| 145 |
-
if (settled) return; settled = true;
|
| 146 |
-
if (
|
|
|
|
| 147 |
try { resolve(JSON.parse(stdout.trim())); }
|
| 148 |
catch { reject(new Error('Resposta inválida do transcritor.')); }
|
| 149 |
});
|
| 150 |
-
child.on('error', err => { if (!settled) { settled = true; reject(err); } });
|
| 151 |
});
|
| 152 |
}
|
| 153 |
|
|
@@ -243,16 +280,15 @@ app.post('/download-video', async (req, res) => {
|
|
| 243 |
await ensureYtDlp();
|
| 244 |
const id = randomUUID();
|
| 245 |
const out = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
|
| 246 |
-
|
| 247 |
-
await ytdlp.execPromise([url,
|
| 248 |
'--format', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
| 249 |
'--merge-output-format', 'mp4',
|
| 250 |
'--ffmpeg-location', ffmpegStatic,
|
| 251 |
-
'--output', out,
|
| 252 |
const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(id));
|
| 253 |
if (!files.length) return res.status(500).json({ error: 'Falha ao baixar vídeo.' });
|
| 254 |
streamAndCleanup(res, path.join(DOWNLOADS_DIR, files[0]), 'video.mp4', 'video/mp4');
|
| 255 |
-
} catch (e) { res.status(500).json({ error:
|
| 256 |
});
|
| 257 |
|
| 258 |
app.post('/download-audio', async (req, res) => {
|
|
@@ -263,13 +299,12 @@ app.post('/download-audio', async (req, res) => {
|
|
| 263 |
const id = randomUUID();
|
| 264 |
const mp3Out = path.join(DOWNLOADS_DIR, `${id}.mp3`);
|
| 265 |
const tmpOut = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
|
| 266 |
-
|
| 267 |
-
await ytdlp.execPromise([url, '--format', 'bestaudio/best', '--output', tmpOut, '--no-playlist', '--quiet']);
|
| 268 |
const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(id) && !f.endsWith('.mp3'));
|
| 269 |
if (!files.length) return res.status(500).json({ error: 'Falha ao baixar áudio.' });
|
| 270 |
await convertToMp3(path.join(DOWNLOADS_DIR, files[0]), mp3Out, true, true);
|
| 271 |
streamAndCleanup(res, mp3Out, 'audio.mp3', 'audio/mpeg');
|
| 272 |
-
} catch (e) { res.status(500).json({ error:
|
| 273 |
});
|
| 274 |
|
| 275 |
// Handler global de erros (ex.: arquivo grande demais no multer)
|
|
|
|
| 18 |
const YTDLP_PATH = process.env.YTDLP_PATH ||
|
| 19 |
path.join(__dirname, 'bin', IS_WIN ? 'yt-dlp.exe' : 'yt-dlp');
|
| 20 |
const JOB_TTL_MS = 60 * 60 * 1000; // 1h: jobs e arquivos temporários expiram
|
| 21 |
+
const DL_TIMEOUT_MS = 3 * 60 * 1000; // 3min: timeout do download (yt-dlp)
|
| 22 |
+
const TR_TIMEOUT_MS = 10 * 60 * 1000; // 10min: timeout da transcrição (Whisper)
|
| 23 |
|
| 24 |
[DOWNLOADS_DIR, UPLOADS_DIR, path.join(__dirname, 'bin')].forEach(d => fs.mkdirSync(d, { recursive: true }));
|
| 25 |
|
|
|
|
| 96 |
return ytdlpReady;
|
| 97 |
}
|
| 98 |
|
| 99 |
+
/** Traduz erros comuns do yt-dlp para mensagens claras */
|
| 100 |
+
function cleanYtDlpError(stderr = '') {
|
| 101 |
+
const s = stderr.toLowerCase();
|
| 102 |
+
if (s.includes('login required') || s.includes('log in') || s.includes('rate-limit') || s.includes('cookies'))
|
| 103 |
+
return 'Esse vídeo exige login (comum em contas privadas ou Instagram). Tente um link público.';
|
| 104 |
+
if (s.includes('private')) return 'Esse vídeo é privado e não pode ser baixado.';
|
| 105 |
+
if (s.includes('unavailable') || s.includes('not available') || s.includes('removed'))
|
| 106 |
+
return 'Vídeo indisponível ou removido.';
|
| 107 |
+
if (s.includes('unsupported url') || s.includes('no video') || s.includes('unable to extract'))
|
| 108 |
+
return 'Não foi possível ler esse link. Verifique se é um link de vídeo válido.';
|
| 109 |
+
if (s.includes('404')) return 'Vídeo não encontrado (404).';
|
| 110 |
+
return 'Não foi possível baixar o vídeo. O link pode estar protegido ou indisponível.';
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/** Roda o yt-dlp via spawn com timeout e kill garantido. `capture` retorna o stdout. */
|
| 114 |
+
function runYtDlp(args, { timeoutMs = DL_TIMEOUT_MS, capture = false } = {}) {
|
| 115 |
+
return new Promise((resolve, reject) => {
|
| 116 |
+
const child = spawn(YTDLP_PATH, args, { stdio: ['ignore', capture ? 'pipe' : 'ignore', 'pipe'] });
|
| 117 |
+
let out = '', err = '', settled = false, timedOut = false;
|
| 118 |
+
const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, timeoutMs);
|
| 119 |
+
if (capture && child.stdout) child.stdout.on('data', d => { out += d.toString(); });
|
| 120 |
+
child.stderr.on('data', d => { err += d.toString(); });
|
| 121 |
+
child.on('close', code => {
|
| 122 |
+
if (settled) return; settled = true; clearTimeout(timer);
|
| 123 |
+
if (timedOut) return reject(new Error('A operação demorou demais e foi cancelada. Tente um vídeo mais curto.'));
|
| 124 |
+
if (code !== 0) return reject(new Error(cleanYtDlpError(err)));
|
| 125 |
+
resolve(out);
|
| 126 |
+
});
|
| 127 |
+
child.on('error', e => { if (!settled) { settled = true; clearTimeout(timer); reject(e); } });
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
// Flags que fazem o yt-dlp falhar rápido em vez de ficar tentando pra sempre
|
| 132 |
+
const YTDLP_BASE = ['--no-playlist', '--no-warnings', '--socket-timeout', '20', '--retries', '2', '--extractor-retries', '1'];
|
| 133 |
+
|
| 134 |
async function getVideoInfo(url) {
|
| 135 |
+
const raw = await runYtDlp([url, '--dump-json', ...YTDLP_BASE], { capture: true, timeoutMs: 45000 });
|
|
|
|
| 136 |
const info = JSON.parse(raw);
|
| 137 |
return {
|
| 138 |
title: info.title || '',
|
|
|
|
| 160 |
async function downloadAudioFromUrl(jobId, url, setStatus = () => {}) {
|
| 161 |
const outMp3 = path.join(DOWNLOADS_DIR, `${jobId}.mp3`);
|
| 162 |
const tmpOut = path.join(DOWNLOADS_DIR, `${jobId}.%(ext)s`);
|
| 163 |
+
await runYtDlp([url, '--format', 'bestaudio/best', '--output', tmpOut, ...YTDLP_BASE]);
|
|
|
|
| 164 |
const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(jobId) && !f.endsWith('.mp3'));
|
| 165 |
+
if (!files.length) throw new Error('Não foi possível baixar o áudio desse vídeo.');
|
| 166 |
setStatus('extracting');
|
| 167 |
await convertToMp3(path.join(DOWNLOADS_DIR, files[0]), outMp3, true);
|
| 168 |
return outMp3;
|
|
|
|
| 173 |
return new Promise((resolve, reject) => {
|
| 174 |
const worker = path.join(__dirname, 'whisper_worker.js');
|
| 175 |
const child = spawn(process.execPath, [worker, mp3Path, modelName, language, task], { stdio: ['ignore', 'pipe', 'pipe'] });
|
| 176 |
+
let stdout = '', stderr = '', settled = false, timedOut = false;
|
| 177 |
+
const timer = setTimeout(() => { timedOut = true; try { child.kill('SIGKILL'); } catch {} }, TR_TIMEOUT_MS);
|
| 178 |
child.stdout.on('data', d => { stdout += d.toString(); });
|
| 179 |
child.stderr.on('data', d => { const l = d.toString().trim(); if (l) { stderr += l + '\n'; console.log('[worker]', l); } });
|
| 180 |
child.on('close', code => {
|
| 181 |
+
if (settled) return; settled = true; clearTimeout(timer);
|
| 182 |
+
if (timedOut) return reject(new Error('A transcrição demorou demais. Tente um vídeo mais curto ou o modelo "Tiny".'));
|
| 183 |
+
if (code !== 0) return reject(new Error('Falha ao transcrever o áudio.'));
|
| 184 |
try { resolve(JSON.parse(stdout.trim())); }
|
| 185 |
catch { reject(new Error('Resposta inválida do transcritor.')); }
|
| 186 |
});
|
| 187 |
+
child.on('error', err => { if (!settled) { settled = true; clearTimeout(timer); reject(err); } });
|
| 188 |
});
|
| 189 |
}
|
| 190 |
|
|
|
|
| 280 |
await ensureYtDlp();
|
| 281 |
const id = randomUUID();
|
| 282 |
const out = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
|
| 283 |
+
await runYtDlp([url,
|
|
|
|
| 284 |
'--format', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
|
| 285 |
'--merge-output-format', 'mp4',
|
| 286 |
'--ffmpeg-location', ffmpegStatic,
|
| 287 |
+
'--output', out, ...YTDLP_BASE]);
|
| 288 |
const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(id));
|
| 289 |
if (!files.length) return res.status(500).json({ error: 'Falha ao baixar vídeo.' });
|
| 290 |
streamAndCleanup(res, path.join(DOWNLOADS_DIR, files[0]), 'video.mp4', 'video/mp4');
|
| 291 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 292 |
});
|
| 293 |
|
| 294 |
app.post('/download-audio', async (req, res) => {
|
|
|
|
| 299 |
const id = randomUUID();
|
| 300 |
const mp3Out = path.join(DOWNLOADS_DIR, `${id}.mp3`);
|
| 301 |
const tmpOut = path.join(DOWNLOADS_DIR, `${id}.%(ext)s`);
|
| 302 |
+
await runYtDlp([url, '--format', 'bestaudio/best', '--output', tmpOut, ...YTDLP_BASE]);
|
|
|
|
| 303 |
const files = fs.readdirSync(DOWNLOADS_DIR).filter(f => f.startsWith(id) && !f.endsWith('.mp3'));
|
| 304 |
if (!files.length) return res.status(500).json({ error: 'Falha ao baixar áudio.' });
|
| 305 |
await convertToMp3(path.join(DOWNLOADS_DIR, files[0]), mp3Out, true, true);
|
| 306 |
streamAndCleanup(res, mp3Out, 'audio.mp3', 'audio/mpeg');
|
| 307 |
+
} catch (e) { res.status(500).json({ error: e.message }); }
|
| 308 |
});
|
| 309 |
|
| 310 |
// Handler global de erros (ex.: arquivo grande demais no multer)
|
templates/index.html
CHANGED
|
@@ -520,11 +520,11 @@ mark{background:#fde68a;border-radius:2px;padding:0 1px}
|
|
| 520 |
<div class="fld" style="margin-bottom:0">
|
| 521 |
<span class="fld-l">Modelo</span>
|
| 522 |
<select class="sel" id="model-sel">
|
| 523 |
-
<option value="Xenova/whisper-tiny">Tiny — rápido</option>
|
| 524 |
-
<option value="Xenova/whisper-base">Base</option>
|
| 525 |
-
<option value="Xenova/whisper-small"
|
| 526 |
-
<option value="Xenova/whisper-medium">Medium</option>
|
| 527 |
-
<option value="Xenova/whisper-large-v3">Large</option>
|
| 528 |
</select>
|
| 529 |
</div>
|
| 530 |
<div class="fld" style="margin-bottom:0">
|
|
@@ -551,6 +551,7 @@ mark{background:#fde68a;border-radius:2px;padding:0 1px}
|
|
| 551 |
<div class="step" id="s2"><div class="sc"></div><div class="slb">Transcrever</div></div>
|
| 552 |
<div class="step" id="s3"><div class="sc"></div><div class="slb">Gerar</div></div>
|
| 553 |
</div>
|
|
|
|
| 554 |
</div>
|
| 555 |
<div class="errbox" id="errbox"></div>
|
| 556 |
<button class="btn pri" id="demo-btn" style="width:100%;margin-top:12px;justify-content:center;padding:10px">▶ Ver demonstração de exemplo</button>
|
|
@@ -797,7 +798,8 @@ G('fclr').addEventListener('click',()=>{S.file=null;G('fi').value='';G('fcho').c
|
|
| 797 |
function setFile(f){if(!f)return;S.file=f;G('fname').textContent=`📎 ${f.name} · ${(f.size/1024/1024).toFixed(1)} MB`;G('fcho').classList.add('on');}
|
| 798 |
|
| 799 |
const SMAP={downloading_ytdlp:0,downloading:0,extracting:1,transcribing:2,analyzing:3,done:'all'};
|
| 800 |
-
|
|
|
|
| 801 |
|
| 802 |
G('proc-btn').addEventListener('click',startProc);
|
| 803 |
G('url-inp').addEventListener('keydown',e=>{if(e.key==='Enter')startProc();});
|
|
|
|
| 520 |
<div class="fld" style="margin-bottom:0">
|
| 521 |
<span class="fld-l">Modelo</span>
|
| 522 |
<select class="sel" id="model-sel">
|
| 523 |
+
<option value="Xenova/whisper-tiny" selected>Tiny — rápido ✦</option>
|
| 524 |
+
<option value="Xenova/whisper-base">Base — equilibrado</option>
|
| 525 |
+
<option value="Xenova/whisper-small">Small — preciso (lento)</option>
|
| 526 |
+
<option value="Xenova/whisper-medium">Medium — muito lento</option>
|
| 527 |
+
<option value="Xenova/whisper-large-v3">Large — máxima qualidade</option>
|
| 528 |
</select>
|
| 529 |
</div>
|
| 530 |
<div class="fld" style="margin-bottom:0">
|
|
|
|
| 551 |
<div class="step" id="s2"><div class="sc"></div><div class="slb">Transcrever</div></div>
|
| 552 |
<div class="step" id="s3"><div class="sc"></div><div class="slb">Gerar</div></div>
|
| 553 |
</div>
|
| 554 |
+
<div id="pg-note" style="font-size:12px;color:var(--ink3);text-align:center;margin-top:10px;line-height:1.5"></div>
|
| 555 |
</div>
|
| 556 |
<div class="errbox" id="errbox"></div>
|
| 557 |
<button class="btn pri" id="demo-btn" style="width:100%;margin-top:12px;justify-content:center;padding:10px">▶ Ver demonstração de exemplo</button>
|
|
|
|
| 798 |
function setFile(f){if(!f)return;S.file=f;G('fname').textContent=`📎 ${f.name} · ${(f.size/1024/1024).toFixed(1)} MB`;G('fcho').classList.add('on');}
|
| 799 |
|
| 800 |
const SMAP={downloading_ytdlp:0,downloading:0,extracting:1,transcribing:2,analyzing:3,done:'all'};
|
| 801 |
+
const SNOTE={downloading_ytdlp:'Preparando ferramentas...',downloading:'Baixando o vídeo...',extracting:'Extraindo o áudio...',transcribing:'Transcrevendo com IA — pode levar de alguns segundos a minutos conforme a duração e o modelo escolhido. ⏳',analyzing:'Gerando o conteúdo...'};
|
| 802 |
+
function setSteps(st){const a=SMAP[st];[0,1,2,3].forEach(i=>{const el=G(`s${i}`);el.classList.remove('ac','dn');if(a==='all')el.classList.add('dn');else if(i===a){el.classList.add('ac');for(let j=0;j<i;j++)G(`s${j}`).classList.add('dn');}});const n=G('pg-note');if(n)n.textContent=SNOTE[st]||'';}
|
| 803 |
|
| 804 |
G('proc-btn').addEventListener('click',startProc);
|
| 805 |
G('url-inp').addEventListener('keydown',e=>{if(e.key==='Enter')startProc();});
|