← Voltar ao arquivo

Local Player: fallback de transcode e Whisper em fila P0-P3

Compatível toca direto via Range. Incompatível vira MP4 fragmentado com seek 416. Legenda é adicional: nunca await antes de video.play().

O play nunca pode depender de IA. Transcode e legenda aqui são coadjuvantes — se faltarem, o vídeo toca igual.

O contexto

Nas partes 1 e 2 eu contei a base e o progresso. Agora a parte pesada: transcode de fallback (server/transcode/jobs.js) e legenda com Whisper local (server/subtitles/pipeline.js com 1300+ linhas).

Minha regra desde o começo: arquivo compatível toca direto; o resto só entra em cena se o <video> der error. E legenda nunca trava o play.

O problema que eu queria resolver

Minha biblioteca mistura .mp4 com .mkv, .avi e .mov que o navegador não decodifica, e muita aula sem legenda e com áudio baixo. Converter tudo pela extensão ia torrar CPU e espaço à toa. E esperar IA antes de dar play ia matar a experiência.

Então eu separei: decisão por codec com ffprobe (nunca por extensão), transcode progressivo no mesmo <video>, e fila de legendas por prioridade que nunca tenta fazer a biblioteca inteira de uma vez.

A decisão mais importante

Duas filas, um semáforo compartilhado.

Transcode: jobs com dedup + FIFO, um por vez. Whisper: P0 o que eu abri, P1 a próxima aula, P2 a primeira de cada curso depois do scan, P3 o resto em lotes de 20. Os dois dividem heavySlots (1 por padrão) — um espera o outro de propósito, não é deadlock. LLM fica fora disso.

flowchart TD
  Erro["<video> error"] --> Fallback["/api/video/fallback"]
  Fallback --> Probe["ffprobe por codec"]
  Probe --> Direto["compatível: serve direto"]
  Probe --> Transcode["incompatível: job FIFO"]
  Aula["abrir aula"] --> P0["P0 demanda"]
  P0 --> P1["P1 próxima aula"]
  P1 --> P2["P2 primeiras pós-scan"]
  P2 --> P3["P3 background"]
  Transcode --> Heavy["heavySlots (1)"]
  P0 --> Heavy

Eu descartei transcodificar tudo no scan: num pendrive ia ser inviável de tempo, CPU e espaço — e ia mexer em arquivo que já tocava bem.

Como usei IA no desenvolvimento

Ela me ajudou muito nos extratores de texto pra o tutor (PDF, Office, código, sem dependência), no web search com anti-SSRF e nos skills de economia de token (server/ai/skills.js: Caveman, RTK, Headroom, ADHD).

Mas tentou três escorregadas: usar +faststart no MP4 progressivo (quebra o fragmentado), usar shell:true com caminho com espaço, e enfileirar a biblioteca inteira. Eu corrigi pra -movflags frag_keyframe+empty_moov+default_base_moof, spawn com array de args e P0-P3 com preempção só de job barato.

O que eu descobri implementando

  • Legenda: ffmpeg extrai WAV 16kHz mono → whisper.cpp → pós determinístico → VTT canônico em <lib>/.courseplayer/subtitles/<hash>.vtt + espelho em data/subtitles/. Raw nunca é sobrescrito; reusa se mtime+size batem; force=1 regenera.
  • Transcode: cache em data/transcoded/sha1(...).mp4, invalida por mtime; .tmp só vira final com rename após exit 0; .tmp órfão dá 404.
  • Seek além do já convertido espera até 60s ou responde 416. Progresso no log a cada 25%.
  • No frontend eu troco o src do mesmo <video> (pra não perder GainNode e listeners); posição e volume voltam quando o trecho fica em buffered; aparece um badge “Preparando compatibilidade…” com “Tentar novamente”.
  • Legenda é overlay customizado, nunca <track>; status via /api/subtitles/status; ao abrir, gera P0 e já enfileira a próxima em P1. Sem binário, modelo ou internet, o status diz available:false e toca normal.
  • Áudio: volume até 100% no controle, além disso via GainNode, com normalizador, velocidade 0.5–2x salva.

O que funcionou

  • Sem ffmpeg: mensagem clara, não 500 cru.
  • Editor de legendas desligado no frontend (sem botão, ?editSubtitles=1 ignorado), mas rotas guardadas; save com versão divergente dá 409, com backup antes de regenerar.
  • Tutor com contexto da aula (transcrição + materiais + timestamps) via SSE, chave só no backend, nunca em log.

O que não funcionou

  • Whisper não roda em FAT/vfat — documentei e o workspace temporário vai pro os.tmpdir().
  • Minha primeira legenda com <track> brigou com estilo e geometria; o overlay medido na área real do vídeo resolveu.

O que eu faria diferente hoje

Ter criado o heavySlots como módulo próprio desde o dia um, em vez de espalhar a lógica entre transcode e whisper. Nasceu como flag e virou invariante.

Resultado

Validado pelo docs/VALIDATION.md: .mkv mostra badge, toca em segundos, loga [TRANSCODE], salva em data/transcoded/; legenda gera raw → VTT canônico + espelho; POST duplicado dedup; P0–P3 sem varrer tudo; limpar transcode não encosta no progresso.

O que aprendi

1. Fallback, não padrão

Decidir por codec depois do error economiza mais CPU que qualquer preset de ffmpeg.

2. Prioridade é produto

P0–P3 diz o que está na minha tela agora; FIFO puro trataria background como urgente.

3. Adicional tem que degradar

Todo caminho de IA tem o available:false testado. Sem isso, vibecoding vira acoplamento.

Próximos passos

Conclusão

Transcode e Whisper são a parte mais vistosa e a mais fácil de errar. O acerto foi tratar os dois como segunda classe do play — com fila, semáforo e degradação explícita.

Referências

  • server/transcode/jobs.js, server/subtitles/pipeline.js, server/subtitles/vtt.js, server/ai/skills.js
  • docs/SUBTITLES.md, docs/whisper.md, docs/VALIDATION.md seções 3 e 6
Diagrama ampliado
100%
↑↓ para navegar · Enter para abrir 8 itens no índice