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>

Files changed (2) hide show
  1. server.js +51 -16
  2. 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 ytdlp = new YTDlpWrap(YTDLP_PATH);
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
- const ytdlp = new YTDlpWrap(YTDLP_PATH);
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 vídeo. Verifique o link.');
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 (code !== 0) return reject(new Error(stderr.split('\n').filter(Boolean).pop() || `Worker saiu com código ${code}`));
 
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
- const ytdlp = new YTDlpWrap(YTDLP_PATH);
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, '--no-playlist', '--quiet']);
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: 'Falha ao baixar vídeo. Pode estar protegido ou indisponível.' }); }
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
- const ytdlp = new YTDlpWrap(YTDLP_PATH);
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: 'Falha ao baixar áudio.' }); }
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" selected>Small </option>
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
- 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');}});}
 
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();});