ABR Offline Converter por dentro: un conversor sin servidor, de ffmpeg.wasm a un EPUB hecho a mano
Cómo funciona un conversor de imágenes, vídeo, PDF, fuentes, subtítulos, texto y datos que no tiene backend. Workers, ffmpeg compilado a WebAssembly, codificadores BMP, ICO y TIFF escritos a mano, 811 páginas prerenderizadas y un Taller del libro que genera EPUB3 sin pandoc.
Convertir archivos es de esas cosas que todo el mundo necesita de vez en cuando, y casi todas las webs que lo hacen piden lo mismo: sube tu archivo y te devolvemos otro. Yo quería lo contrario, un conversor en el que el archivo no se moviera de mi equipo. Así salió ABR Offline Converter. Qué hace y cómo se usa está en el artículo para usuarios. Este cuenta cómo está hecho.
La regla de partida fue que no hubiera backend. Todo lo que haría un servidor (decodificar, recodificar, empaquetar) lo tiene que hacer el navegador. Casi todo se puede, con algunas trampas por el camino.
Por qué una web y no una extensión
Antes de escribir código dudé entre una extensión de navegador y una aplicación web. Ganó la web por el vídeo: el motor de vídeo que iba a usar, ffmpeg compilado a WebAssembly, pesa unos 30 MB, y una extensión tiene que llevarlo dentro y vive con límites de tiempo de ejecución que chocan con convertir un vídeo largo. Una web puede cargarlo solo cuando hace falta, guardarlo en caché e instalarse como aplicación.
El stack es Nuxt 4 con Vue 3 y TypeScript, generado como sitio estático y servido desde GitHub Pages. No hay ninguna ruta de servidor que procese archivos. La aplicación es una PWA con un service worker que guarda en caché las páginas y los recursos, así que se puede instalar y usar sin conexión. Con un matiz que tengo pendiente: la precarga guarda js, css, html y poco más, y deja fuera el worker de pdf.js (.mjs) y el módulo de WOFF2 (.wasm). Sin red, pasar un PDF a imágenes y cualquier conversión de fuentes (todas inicializan el módulo de WOFF2) dependen de que el navegador aún los tenga en su caché normal.
El trabajo pesado va en Web Workers, para que la interfaz no se congele mientras convierte:
Cada tipo de archivo tiene su composable (useImageConverter, useMediaConverter, usePdfConverter…) con la misma forma: una cola de archivos con su estado, unas opciones generales y, encima, opciones por archivo para el que quiera algo distinto. Cuando hay varios resultados, client-zip los empaqueta en un ZIP en el propio navegador.
Imágenes: el canvas y lo que el canvas no sabe hacer
El camino normal es sencillo. El worker recibe el archivo, lo convierte en un ImageBitmap, lo dibuja en un OffscreenCanvas con el tamaño pedido y llama a convertToBlob() con el tipo y la calidad. Así salen PNG, JPG y WebP.
El problema es que el canvas solo exporta unos pocos formatos. Para el resto hay que escribir el archivo por otro lado:
- GIF con
gifenc, que reduce la imagen a una paleta de 256 colores. - BMP y TIFF con codificadores que escribí a mano a partir de los píxeles de
getImageData(), y ICO envolviendo el PNG que sí da el canvas. Los tres a mano, porque son formatos lo bastante simples como para no meter otra dependencia.
El ICO, por ejemplo, es un contenedor: 6 bytes de cabecera, 16 de entrada de directorio y, detrás, un PNG tal cual. Los lectores modernos sacan el tamaño real de la cabecera del PNG:
view.setUint16(2, 1, true) // type: 1 = ICO
view.setUint16(4, 1, true) // count: 1 image
bytes[6] = 0 // width (0 = 256 px)
bytes[7] = 0 // height (0 = 256 px)
view.setUint32(14, pngBytes.length, true)
view.setUint32(18, 22, true) // el PNG empieza tras 6 + 16 bytes
El BMP son 54 bytes de cabecera y los píxeles pasados de RGBA a BGRA. El TIFF, una cabecera y un único directorio de etiquetas con los píxeles sin comprimir. Entre los tres son menos de 150 líneas, cada uno con sus tests.
HEIC, o por qué no todo cabe en un worker
Las fotos del iPhone llegan en HEIC, que ningún navegador fuera de Safari sabe abrir. Lo resuelve heic2any, que decodifica a PNG. Lo metí en el worker y todas las conversiones fallaban con ERR_CANVAS. La librería crea su canvas con document.createElement('canvas'), y en un worker no hay document.
La solución fue partir el camino: el HEIC se decodifica a PNG en el hilo principal y lo que llega al worker ya es una imagen normal. Es lo único que se sale del patrón, y está aislado en una función.
AVIF
AVIF está en la lista de formatos porque el navegador lo abre sin problemas. Para codificarlo, convertToBlob({ type: 'image/avif' }) depende del navegador, y Chrome hoy no lo soporta: devuelve un PNG sin quejarse. Mientras no meta un codificador AVIF en WebAssembly, AVIF sirve de verdad como formato de entrada, y como salida depende del navegador.
Vídeo y audio con ffmpeg.wasm
Para vídeo y audio no tenía sentido reinventar nada: ffmpeg compilado a WebAssembly (@ffmpeg/ffmpeg) corre en su propio worker.
El motor no va en el bundle. La primera vez que conviertes algo de vídeo o audio, el worker lo descarga con toBlobURL desde unpkg, y el service worker lo guarda con una estrategia cache first durante un año. A partir de ahí, ni siquiera hace falta red. Lo que viaja es el programa, nunca tus archivos: el vídeo se escribe en el sistema de archivos virtual de ffmpeg, en memoria, y de ahí sale el resultado.
Cada conversión es construir los argumentos de ffmpeg a partir de las opciones. Sacar el audio de un vídeo es -vn y el códec que toca (libmp3lame para MP3, libopus para Opus…). El GIF tiene su propio camino:
const fps = opts.fps ?? 10
const w = opts.targetWidth ?? 480
return ['-i', inputName, '-vf', `fps=${fps},scale=${w}:-1:flags=lanczos`, '-an', outputName]
Diez fotogramas por segundo, 480 píxeles de ancho escalados con lanczos y sin audio: un GIF que se ve bien y no pesa una barbaridad. El recorte se añade encima de cualquier conversión, metiendo -ss antes de la entrada y -t con la duración justo antes del archivo de salida.
Para cambiar de contenedor (de MKV a MOV, por ejemplo) sin tocar resolución, fotogramas ni bitrate, el vídeo se copia con -c:v copy en vez de recodificarlo, que en el navegador es la diferencia entre segundos y minutos.
El listener que se quedaba
La barra de progreso escucha el evento progress de ffmpeg. En la primera versión, cada conversión añadía un listener nuevo y ninguno se quitaba, así que al tercer vídeo el progreso se actualizaba tres veces por evento. Ahora el handler se guarda en una variable, se registra al empezar y se quita en un finally, falle o no la conversión.
Comparar antes y después
Antes de descargar quería ver qué había hecho la compresión. Para imágenes es un componente con las dos versiones superpuestas y un divisor que se arrastra con pointer events.
Para vídeo, el modal salta a un instante del original y del convertido (video.currentTime), espera al evento seeked y dibuja los dos fotogramas en canvas. El deslizador va con un debounce de 300 ms, porque buscar en un vídeo es caro y no tiene sentido hacerlo con cada píxel que mueves el ratón.
El resto de tipos, en corto
- PDF.
pdf.jsdibuja cada página en un canvas para exportarla como imagen, ypdf-libcrea el PDF al revés, una página por imagen. WebP no se puede incrustar en un PDF, así que antes se pasa a PNG. - Fuentes.
fonteditor-corelee y escribe TTF, OTF y WOFF. WOFF2 necesita un módulo WebAssembly aparte que hay que inicializar antes de usarlo, y la primera versión no lo hacía. - Subtítulos. SRT, VTT y ASS con la librería
subtitle. La versión 4 no funcionaba en el navegador, así que bajé a la 3. ASS tiene su propia forma de escribir los saltos de línea dentro de un subtítulo, y hubo que serializarlos aparte. - Texto.
markedpara Markdown a HTML yTurndownpara lo contrario. Un EPUB se abre como ZIP, se leen el manifest y el orden de lectura, y se rechaza si tiene DRM. - Datos.
Papa Parsepara CSV. Los primeros CSV reales rompieron la conversión: un salto de línea al final o un archivo de una sola columna generaban avisos que estaba tratando como errores fatales. Y al pasar de JSON a CSV,Papa.unparsesaca las columnas de la primera fila, así que si las filas tenían claves distintas se perdían columnas. Ahora las columnas son la unión de las claves de todas las filas, después de aplanar los objetos anidados con puntos.
192 páginas, una por conversión
Quien busca "convertir csv a json" no busca un conversor genérico; quiere esa conversión. Así que cada par de formatos tiene su página: /convertir/csv-a-json, /convert/csv-to-json en inglés, con su título, su descripción y el conversor ya configurado.
No las escribí a mano. Hay un registro único con los formatos de cada tipo, y de ahí salen las páginas de categoría, las de par, el índice de todas las conversiones y el sitemap.xml. Añadir un formato a un tipo es añadirlo a una lista. Salen 192 pares por idioma.
Al generar el sitio llegó el susto: fallaban todas las páginas de conversión, unas 400, con un error 500 que en desarrollo no aparecía. El mensaje no ayudaba mucho (Cannot read properties of null (reading 'ce')). La causa estaba en la primera línea de los tres componentes de esas páginas:
await setLocale(props.locale)
Después de un await en <script setup>, Vue tiene que restaurar la instancia del componente, y todo apunta a que eso es lo que se rompía al prerenderizar con Nitro, aunque nunca llegué a aislar el mecanismo exacto. La solución fue no esperar: cambiar el idioma de forma síncrona, cargando los mensajes del idioma de la página con mergeLocaleMessage() y cambiando locale.value directamente. No bastaba con esto último: @nuxtjs/i18n registra los mensajes de cada idioma vacíos al arrancar y los rellena dentro de setLocale(), así que sin cargarlos a mano las páginas en inglés salían con las claves de traducción en crudo. Después del cambio, el sitio genera 811 rutas sin un fallo, frente a las 11 de antes.
El Taller del libro por dentro
Esta parte nació de una necesidad mía. Escribo novelas con un Markdown por capítulo y las compilaba con un script de Python que llamaba a pandoc. El modo Texto del conversor ya pasaba Markdown a EPUB, pero de un solo archivo: todo en un documento, sin índice real, sin autor ni portada. Para un libro no servía, así que monté una página aparte, el Taller del libro.
La lógica está separada de la interfaz, en funciones puras con sus tests:
chapters.tsordena los capítulos (orden natural, el 2 antes del 10), saca el título del primer# H1o del nombre del archivo, quita los números que ya traiga (1.,Capítulo 1:,IV -…) y numera según la posición, saltando prólogos, epílogos e interludios.typography.tscorrige la tipografía sobre el HTML: comillas rectas a « » con las anidadas a “ ” y ‘ ’ según la profundidad,...a…y apóstrofos a ’, sin tocar el código ni los atributos.render.tsgenera el XHTML de cada capítulo y de las páginas extra, con su CSS.cover.tsdibuja la portada tipográfica en un canvas de 1600×2400 y la exporta a JPG. Busca el tamaño de letra más grande con el que el título cabe en las líneas que permite, y si no, baja hasta un mínimo y parte el título en más líneas.epubPackage.tslo empaqueta todo.
Un EPUB es un ZIP con normas
Un EPUB es un ZIP, pero no vale cualquier ZIP. El primer archivo tiene que llamarse mimetype, contener application/epub+zip y no estar comprimido, porque los lectores lo leen en una posición fija del archivo para saber qué tienen delante. client-zip guarda los archivos sin comprimir, así que basta con ponerlo el primero:
const entries = [
{ name: 'mimetype', input: 'application/epub+zip' },
{ name: 'META-INF/container.xml', input: containerXml },
{ name: 'EPUB/content.opf', input: contentOpf },
{ name: 'EPUB/nav.xhtml', input: navXhtml },
{ name: 'EPUB/toc.ncx', input: tocNcx },
// …estilos, portada, páginas y capítulos
]
Detrás van el content.opf con los metadatos (título, autor, idioma, identificador y, si hay serie, belongs-to-collection), el índice de EPUB3 (nav.xhtml, con landmarks para la portada, el índice y el inicio del texto) y un toc.ncx para los lectores antiguos. La portada va marcada como cover-image en el manifest y con un <meta name="cover"> para los que no entienden lo anterior.
XHTML no es HTML
El fallo que más me enseñó fue que XHTML es XML. Un Markdown puede llevar HTML suelto (un <br> sin cerrar, un ), y lo que en una web es perfectamente válido rompe un XHTML. Un <br> tiene que ser <br/>. Y XML solo conoce cinco entidades con nombre, así que , — o … dejan el capítulo sin abrir en muchos lectores. Ahora el HTML suelto se cierra bien y las entidades con nombre se traducen a numéricas ( ) antes de escribir el archivo.
Lo que ya no hace pandoc
El script antiguo tenía una costumbre fea: si un capítulo no empezaba por #, le escribía un título dentro del archivo. El taller nunca toca los originales. Todo lo que arregla (títulos, números, comillas) lo arregla en el libro, y lo que no puede decidir solo lo convierte en una opción con un valor por defecto razonable. Los datos del libro, la portada y el estilo se guardan en IndexedDB, para que al volver solo haya que soltar los capítulos otra vez.
Cómo lo hice
El proyecto tiene 227 commits repartidos en nueve días de trabajo, entre el 23 de junio y el 22 de octubre. Casi cada función siguió el mismo ciclo: especificación, plan, implementación con tests. Los documentos de cada uno se quedan en el repositorio, en docs/superpowers/. Así se hicieron las imágenes, el vídeo, el PDF, las fuentes, los subtítulos, las rutas por conversión y el taller. Ese ciclo, con una revisión al final de cada plan, es lo que cazó cosas como el listener de ffmpeg.
Hoy hay 305 tests en 27 archivos, con Vitest. Cubren los codificadores byte a byte, el cálculo de dimensiones, la construcción de nombres de archivo, el parseo de CSV y JSON, los subtítulos, las rutas y el sitemap, y el taller entero: orden, títulos, numeración, tipografía, XHTML y empaquetado.
El código vive en un repositorio privado. En cada push a main, GitHub Actions pasa los tests, genera el sitio con nuxt generate y lo sube a un repositorio público servido por GitHub Pages.
Lo que me llevo
Que el navegador da para mucho más de lo que parece. Con workers, WebAssembly y un par de librerías bien elegidas hace sin problema lo que hace unos años pedía un servidor. Los límites están en sitios concretos: un formato que el canvas no exporta, una librería que da por hecho que existe document, un ZIP que tiene que cumplir sus normas al byte. Esos sitios son los que hay que conocer.
Y que una herramienta que uso yo mismo es la mejor forma de encontrar sus fallos. Los tests cubrían el Markdown que yo esperaba; el HTML suelto y las entidades llegaron después.