Decodificar vídeo en paralelo, dentro de una pestaña
Video2Any tiene una restricción que decide todo lo demás: tu vídeo no sale de tu máquina. No hay subida, así que no hay servidor haciendo el trabajo, así que decodificar, comparar y renderizar ocurre todo dentro de una pestaña que además tiene que seguir respondiendo.
Esto es lo que cuesta de verdad, y sobre qué está construido el resto del sitio.
El demuxer te toca escribirlo
WebCodecs te da un VideoDecoder que convierte fragmentos codificados en fotogramas. No te da los fragmentos. El navegador tiene un parser de MP4 —es como funciona <video>— y no hay API que te lo entregue, así que el contenedor lo lees tú: recorres las cajas, encuentras moov, lees la tabla de muestras y le pasas al decodificador un EncodedVideoChunk cada vez.
Tiene un efecto secundario agradable. Si ya estás leyendo el contenedor, puedes decirle a alguien por qué su archivo no va a abrirse antes de gastar cinco minutos fallando: ProRes es un formato de edición que ningún navegador decodifica, y eso se descubre leyendo su moov, que en ProRes suele estar al final del archivo y no al principio. Por eso nuestra sonda mira los dos extremos.
Dos pasadas, por memoria
Lo evidente sería decodificar todos los fotogramas y compararlos. Una grabación de 92 minutos a 25fps son 138.000 fotogramas; a 1080p eso no cabe en una pestaña.
Así que va en dos pasadas. El barrido decodifica solo los puntos de muestreo y guarda cada uno a 160×90: lo bastante pequeño para que un vídeo entero sean unas decenas de megas de píxeles, lo bastante grande para distinguir una diapositiva de otra. Solo cuando el detector ha elegido, la pasada de captura vuelve a por los fotogramas a resolución completa, y solo los elegidos. Un techo de 900 muestras limita la primera pasada, con una consecuencia curiosa: un vídeo de 29 minutos y uno de 92 cuestan casi lo mismo, porque el coste es por muestra, no por minuto.
Un worker por trozo, cada uno con su decodificador
El vídeo se parte por tiempo y cada trozo va a un worker con su propio VideoDecoder. El pool es min(4, núcleos / 2): la mitad de los núcleos, porque la pestaña todavía tiene una interfaz que mover, y tope de cuatro por lo que cuenta la sección siguiente.
Cada trozo empieza a barrer un poco antes de su propio rango, para que el detector tenga con qué comparar su primer fotograma, y luego descarta lo que cae fuera de lo suyo. Las diapositivas salen según se captura cada una, no al final.
Lo que cuesta trocear
Dos cosas, y ahí está lo interesante. Cada trozo calibra su umbral con su propio fragmento, así que el mismo vídeo partido en cuatro y en ocho da presentaciones distintas: el resultado depende de cuántos núcleos tenga la máquina. Y cada trozo quitaba duplicados solo dentro de sí mismo, así que un plano al que una reunión vuelve una y otra vez sobrevivía una vez por trozo: tres diapositivas repetidas de nueve en una grabación de 29 minutos.
Los duplicados están arreglados: la comprobación pasó al hilo principal y corre según llega cada diapositiva. La calibración no. Construimos la versión global y empeoró el resultado: en esa misma reunión, donde ffmpeg cuenta siete cortes reales, calibrar por trozo da seis y globalmente da cuatro. Así que el pool sigue en cuatro, porque más trozos es un 25% más rápido y sigue cambiando la respuesta.
No se puede enseñar nada hasta leer el vídeo entero
El umbral sale de la distribución de diferencias entre fotogramas de todo el vídeo, así que la primera diapositiva no se puede elegir hasta haber muestreado el último. En una grabación de 92 minutos el barrido tarda 52 segundos y la primera diapositiva aparece en el 50.
No es un problema de rendimiento y no lo arregla ningún grado de paralelismo. Es un problema de información, y se resolvió escribiendo «Leyendo el vídeo · 45:46 de 92:08» donde antes solo había un porcentaje.
El resto de la pila
Cloudflare Workers con Hono para la API, D1 para cuentas y metadatos, R2 para las pocas cosas que se guardan. React 19 con TanStack Router, Tailwind v4, better-auth. Ocho idiomas, cada uno con su prefijo de URL y su basepath de router.
El sitio es una SPA prerenderizada, no renderizada en servidor: cada página pública es una instantánea HTML estática generada al desplegar, y por eso las páginas de marketing son cacheables y rápidas y el conversor funciona sin servidor ninguno. Rompemos eso exactamente en un sitio. Las páginas /app son cascarones estáticos, así que el editor arrancaba sin saber nada de tu cuenta y tenía que preguntar, y empezar a trabajar antes de que llegara la respuesta es como un suscriptor de pago acabó con el límite de treinta minutos del plan gratuito. El worker ya tiene la sesión cuando sirve esas páginas, así que escribe el plan en el documento y el primer render ya lo sabe. Esas respuestas son privadas y no se cachean nunca; el resto conserva su caché pública.
Por qué molestarse
Un servidor haría desaparecer casi todo esto. También significaría que cada clase, cada reunión interna y cada grabación médica que alguien convierte pasa por nuestras máquinas, y todo el sentido del producto es que no lo hagan.
La restricción es la función. Lo de arriba es lo que cuesta.