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 emdata/subtitles/. Raw nunca é sobrescrito; reusa semtime+sizebatem;force=1regenera. - Transcode: cache em
data/transcoded/sha1(...).mp4, invalida por mtime;.tmpsó 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
srcdo mesmo<video>(pra não perder GainNode e listeners); posição e volume voltam quando o trecho fica embuffered; 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 dizavailable:falsee 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=1ignorado), 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.jsdocs/SUBTITLES.md,docs/whisper.md,docs/VALIDATION.mdseções 3 e 6