Decodificando vídeo em paralelo, dentro de uma aba
O Video2Any tem uma restrição que decide todo o resto: o seu vídeo não sai da sua máquina. Não há upload, então não há servidor fazendo o trabalho, então decodificar, comparar e renderizar acontece tudo dentro de uma aba que ainda precisa continuar respondendo.
Aqui está o que isso custa de verdade, e sobre o que o resto do site é construído.
O demuxer você escreve
O WebCodecs te dá um VideoDecoder que transforma trechos codificados em quadros. Ele não te dá os trechos. O navegador tem um parser de MP4 — é assim que o <video> funciona — e nenhuma API te entrega isso, então o contêiner você lê: percorre as boxes, acha o moov, lê a tabela de amostras e passa um EncodedVideoChunk de cada vez para o decodificador.
Isso tem um efeito colateral bom. Já que você está lendo o contêiner, dá para dizer por que um arquivo não vai abrir antes de gastar cinco minutos falhando: ProRes é formato de edição que navegador nenhum decodifica, e você descobre isso lendo o moov, que no ProRes costuma ficar no fim do arquivo e não no começo. É por isso que nossa sonda varre as duas pontas.
Duas passadas, por causa da memória
O óbvio seria decodificar todos os quadros e comparar. Uma gravação de 92 minutos a 25fps são 138.000 quadros; em 1080p isso não cabe numa aba.
Então são duas passadas. A varredura decodifica só os pontos de amostragem e guarda cada um em 160×90: pequeno o bastante para o vídeo inteiro caber em algumas dezenas de megas de pixels, grande o bastante para distinguir um slide do outro. Só depois de o detector escolher é que a captura volta para pegar os quadros em resolução cheia, e só os escolhidos. Um teto de 900 amostras limita a primeira passada, com uma consequência curiosa: um vídeo de 29 minutos e um de 92 custam quase o mesmo, porque o custo é por amostra, não por minuto.
Um worker por pedaço, cada um com seu decodificador
O vídeo é cortado por tempo e cada pedaço vai para um worker com seu próprio VideoDecoder. O pool é min(4, núcleos / 2): metade dos núcleos, porque a aba ainda tem uma interface para mexer, e teto de quatro pelo motivo da próxima seção.
Cada pedaço começa a varrer um pouco antes do seu intervalo, para o detector ter com o que comparar o primeiro quadro, e depois descarta o que está fora do que é dele. Os slides saem conforme cada captura termina, não no fim.
O preço de fatiar
Duas coisas, e é aí que fica interessante. Cada pedaço calibra o limiar no próprio trecho, então o mesmo vídeo cortado em quatro e em oito dá apresentações diferentes: o resultado depende de quantos núcleos a máquina tem. E cada pedaço tirava duplicatas só dentro de si, então uma cena para a qual a reunião volta o tempo todo sobrevivia uma vez por pedaço: três slides repetidos de nove numa gravação de 29 minutos.
As duplicatas estão resolvidas: a checagem foi para a thread principal e roda conforme cada slide chega. A calibração não. Construímos a versão global e o resultado piorou: na mesma reunião, onde o ffmpeg conta sete cortes reais, calibrar por pedaço dá seis e globalmente dá quatro. Então o pool continua em quatro, porque mais pedaços é uns 25% mais rápido e ainda muda a resposta.
Nada pode ser mostrado antes de o vídeo ser lido
O limiar vem da distribuição das diferenças entre quadros do vídeo inteiro, então o primeiro slide não pode ser escolhido antes de o último quadro ser amostrado. Numa gravação de 92 minutos a varredura leva 52 segundos e o primeiro slide aparece no segundo 50.
Não é problema de desempenho e nenhum grau de paralelismo resolve. É problema de informação, e foi resolvido escrevendo "Lendo o vídeo · 45:46 de 92:08" onde antes havia só uma porcentagem.
O resto da stack
Cloudflare Workers com Hono para a API, D1 para contas e metadados, R2 para as poucas coisas que ficam guardadas. React 19 com TanStack Router, Tailwind v4, better-auth. Oito idiomas, cada um com seu prefixo de URL e seu basepath de router.
O site é uma SPA pré-renderizada, não renderizada no servidor: cada página pública é um snapshot de HTML estático gerado no deploy, e é por isso que as páginas de marketing são cacheáveis e rápidas e o conversor funciona sem servidor algum. A gente quebra isso em exatamente um lugar. As páginas /app são cascas estáticas, então o editor começava sem saber nada da sua conta e precisava perguntar — e começar a trabalhar antes de a resposta voltar foi como um assinante pagante acabou com o limite de trinta minutos do plano grátis. O worker já tem a sessão quando serve essas páginas, então escreve o plano no documento e a primeira renderização já sabe. Essas respostas são privadas e nunca cacheadas; o resto mantém o cache público.
Por que se dar ao trabalho
Um servidor faria quase tudo isso sumir. Também significaria que toda aula, toda reunião interna e toda gravação médica que alguém converte passa pelas nossas máquinas — e a razão de o produto existir é justamente que não passem.
A restrição é a funcionalidade. O de cima é o preço dela.