PNG, base64 y otras formas de gastar 69 MB para mostrar 5

PNG, base64 y otras formas de gastar 69 MB para mostrar 5

Los números de este artículo están medidos sobre datos reales, no estimados. Al final hay un script para que cualquiera los reproduzca.

Tengo un archivo personal de vídeos. Le reenvío un reel a un bot de Telegram y me lo devuelve transcrito, buscable y ordenado en cuadernos. Cada entrada muestra una miniatura del vídeo para reconocerla de un vistazo.

Un día miré el archivo y noté algo raro: las entradas recientes tenían imagen y las antiguas no. Ninguna en concreto se había roto. Simplemente, cuanto más atrás mirabas, menos fotos había.

La primera hipótesis fue la obvia: «habrá vídeos de los que no se pudo sacar miniatura». Falsa. Al mirar la base de datos, todas tenían miniatura guardada. El campo estaba relleno.

Lo que estaba guardado no era una imagen. Era un enlace.

https://scontent.xx.fbcdn.net/v/t51.82787-10/527029736_1806292…&oe=6A4824F7

Ese oe=6A4824F7 del final es una marca de tiempo en hexadecimal. Traducida: 3 de julio de 2026, 21:09 UTC. Es la fecha de caducidad de la firma del enlace. Pasada esa fecha, la red de distribución de contenidos (CDN) de Facebook responde 403 Forbidden y ya no sirve la imagen a nadie.

Yo no estaba guardando fotos. Estaba guardando vales caducables para pedir fotos prestadas.

La radiografía

Decodifiqué la fecha de caducidad de las 172 entradas del archivo:

Entradas totales172
Sin miniatura capturada9
Con miniatura CADUCADA150
Con miniatura viva13

Las 13 vivas eran exactamente las de aquel mismo día. Y todas caducaban cuatro días después.

Vida útil mediana de una miniatura: 4 días.

Por eso el fallo era invisible: no rompía nada de golpe. Cada entrada se apagaba sola cuatro días después de entrar, y el archivo se iba quedando en blanco por detrás mientras la parte de delante siempre se veía bien.

Este es el tipo de fallo más peligroso que existe. No falla al desplegar, no sale en los tests, no da error en ningún log. Se manifiesta como una degradación lenta que solo notas si miras hacia atrás.

La solución obvia y las tres decisiones que esconde

La solución es evidente: guardar la imagen, no el enlace. Descargarla una vez y quedármela.

Lo interesante no es esa frase. Es que esconde tres decisiones independientes, y solo una de las tres es la que realmente mueve la aguja:

  1. En qué envase la guardo (bytes crudos o texto codificado).
  2. En qué formato la guardo (PNG, JPEG, WebP…).
  3. Dónde la guardo (dentro de la fila, en otra tabla, en disco).

Vamos una por una, con los números reales de una miniatura de mi archivo: una imagen de 540×960 píxeles.

Decisión 1: el envase — base64 vs BLOB

La duda que le surge a mucha gente al meter un binario en una base de datos es si hay que codificarlo en base64 primero.

Base64 convierte binario en texto: coge los bytes de tres en tres y los escribe como cuatro caracteres ASCII. Esa proporción 4÷3 es exactamente el coste:

Tamaño
PNG original323,8 KB
El mismo PNG en base64431,7 KB (+33 %)
El mismo PNG crudo en un BLOB323,8 KB (+0 %)

Un tercio más, siempre, a cambio de nada. Un BLOB —Binary Large Object, objeto binario grande— es simplemente una columna diseñada para guardar bytes sin interpretarlos.

Base64 existe por una razón legítima: atravesar canales que solo admiten texto. Incrustar una imagen dentro de un HTML con data:, meterla en un JSON, mandarla por correo. En esos sitios no hay alternativa.

Pero una columna BLOB ya admite binario. Codificar para guardar en un sitio que acepta binario es pagar el peaje de un puente que no necesitas cruzar: un tercio más de disco, un tercio más de tráfico contra la base de datos, y CPU de codificar al escribir y descodificar al leer, en cada acceso.

Para ser honestos, el BLOB no es gratis del todo: InnoDB —el motor de almacenamiento por defecto de MySQL— guarda con él un puntero de 20 bytes en la fila y las cabeceras de las páginas de desbordamiento de 16 KB. En un fichero de 2 MB son unos 5 KB: un 0,2 %. Es ruido, pero conviene decirlo. El +33 % de base64 no es ruido.

Decisión 2: el formato — por qué una foto nunca va en PNG

Aquí es donde aparece la diferencia grande, y no es cuestión de gustos: PNG y JPEG comprimen cosas distintas porque están diseñados para contenidos distintos.

PNG es sin pérdida. Comprime buscando patrones que se repiten por filas de píxeles. Brilla con colores planos y bordes duros: un logo, una captura de pantalla, un diagrama. Ahí hay zonas enormes idénticas y las resume en nada.

Con una fotografía no encuentra nada que repetir. Por el ruido del sensor, cada píxel es ligeramente distinto de su vecino. PNG acaba guardando casi píxel a píxel.

JPEG es con pérdida y está pensado para fotos. Hace tres cosas que PNG no:

  • Divide la imagen en bloques de 8×8 y les aplica una transformada de coseno discreta (DCT), que separa el detalle grueso del fino.
  • Tira el detalle fino, que es precisamente donde el ojo humano no llega.
  • Trabaja en YCbCr (brillo separado del color) en vez de RGB (rojo-verde-azul mezclados), y guarda el color a la mitad de resolución (chroma subsampling), porque somos mucho más sensibles a los cambios de luminosidad que a los de tono.

El resultado con mi imagen real:

FormatoTamañoDiferencia
PNG323,8 KB
JPEG (misma resolución)33,7 KB9,6× menos

Y a simple vista son indistinguibles, porque es una foto.

El parámetro de calidad

Calidad 82 no es un número mágico, pero está muy cerca del punto óptimo:

  • Por debajo de 75: empiezan a verse los bloques de la DCT en los degradados suaves —cielos, desenfoques, sombras—. Es el aspecto «sucio» del JPEG mal configurado.
  • Por encima de 90: el fichero engorda deprisa y la mejora ya no se aprecia.
  • Entre 80 y 85: el codo de la curva. Máxima calidad percibida por byte.

Cuándo PNG sí es la respuesta

Que quede claro, porque esto no va de «PNG malo»:

ContenidoFormato
Fotos, fotogramas de vídeoJPEG (o WebP/AVIF)
Logos, texto, capturas de pantalla, transparenciaPNG (o WebP sin pérdida)

Si metes una captura de pantalla con texto pequeño en un JPEG, el resultado es malo de verdad: la DCT emborrona los bordes duros y las letras salen con halos sucios alrededor. Cada formato para lo suyo.

Decisión 3: el tamaño — el ahorro que todo el mundo olvida

Esta es la que más gente se salta, y suele ser la mayor.

Los píxeles crecen al cuadrado. Mi tarjeta pinta la miniatura a unos 330 píxeles de ancho. Si guardo el original de 1080 px, el navegador se descarga 1080×608 = 656.000 píxeles para dibujar 330×186 = 61.000.

Estoy tirando el 90 % de los bytes en la operación de reescalar.

¿Por qué 640 px y no 330? Por las pantallas de alta densidad. En un portátil moderno o en cualquier móvil, un píxel CSS son dos físicos. 330 × 2 = 660, redondeado a 640. Se ve nítida donde tiene que verse y no sobra casi nada.

Es el mismo error, con otro disfraz, que escanear un documento a 600 puntos por pulgada (DPI) para leerlo en pantalla: guardar resolución que ningún ojo va a ver. Volveremos a esto con los PDF.

Los números finales, juntos

La misma imagen, según cuántas de las tres decisiones aciertes:

Cómo la guardasTamaño×
PNG en base64431,7 KB12,2×
PNG crudo en BLOB323,8 KB9,2×
JPEG original (540 px)33,7 KB1,0×
JPEG 640 px, calidad 8235,4 KBreferencia

Y escalado a mi archivo entero de 163 miniaturas:

JPEG 640px q82   →    5,6 MB
PNG + base64     →     69 MB

Doce veces más para ver exactamente lo mismo.

Y esos 69 MB no solo ocupan disco: viajan por la red en cada consulta, se cargan en memoria y engordan la copia de seguridad diaria.

Elegir BLOB en vez de base64 ahorra un 33 %. Elegir bien el formato y el tamaño ahorra un 97 %. El envase es la decisión pequeña; el contenido es la grande.

Dónde guardarlos: la decisión que se paga en silencio

Queda el «dónde», y aquí hay una trampa que no se ve hasta que el sistema tiene uso.

Lo natural sería añadir una columna imagen BLOB a la tabla que ya tienes. No lo hagas. Ponla en una tabla aparte, referenciada por clave.

Por qué, técnicamente

En InnoDB, una fila vive dentro de una página de 16 KB. Cuando una columna no cabe, el motor la manda a páginas de desbordamiento y deja en la fila un puntero de 20 bytes. Así que el BLOB, en teoría, no engorda la fila.

Pero el listado de mi archivo hace esto:

SELECT * FROM entries;   -- 163 filas

Y SELECT * sí se trae el BLOB. Pintar una página de texto significaría leer 10 MB de imágenes que nadie va a mirar: viajan por la red, se cargan en memoria y se tiran.

Hay un segundo daño, peor porque no se ve: esas páginas de desbordamiento entran en el buffer pool —la memoria caché de la base de datos— y expulsan de ahí las páginas de datos que sí se usan constantemente. Envenenas la caché con imágenes para acabar sirviendo texto más lento.

Con la tabla separada, la tabla principal sigue siendo texto puro y rápido, y los bytes solo se leen cuando el navegador pide esa imagen concreta: una fila, por su clave primaria.

Es la misma idea por la que PostgreSQL inventó TOAST (The Oversized-Attribute Storage Technique) y por la que casi todos los mapeadores objeto-relacional (ORM) separan el modelo del fichero. Lo grande y frío, lejos de lo pequeño y caliente.

¿Y por qué no en disco, directamente?

Es la pregunta correcta, porque guardar ficheros en disco y servirlos con nginx es más rápido que cualquier base de datos. Ni Python de por medio ni consulta.

En mi caso no valía por dónde vive cada pieza: el proceso que descarga la imagen corre en mi PC y la web que la muestra corre en un servidor. Un fichero en mi disco no lo ve el servidor. Tendría que subirlo por SSH en cada transcripción, y entonces la copia de seguridad deja de ser un volcado de la base de datos y pasa a ser un volcado más una carpeta que hay que sincronizar aparte y que puede desincronizarse.

La base de datos es lo único que ambas máquinas comparten. Y de regalo: las imágenes entran gratis en el backup diario, y son transaccionales —si algo falla a medias, no queda una imagen huérfana sin su fila—.

La regla, por orden de magnitud:

TamañoCuántosDónde
Decenas de KBCientosBLOB en la base de datos
Unos MBCientosFrontera; depende de tus backups
MB o GBMilesDisco u object storage, y en la BD solo la ruta

El límite práctico no es el que pone el motor (LONGBLOB aguanta 4 GB). Es todo lo demás: el max_allowed_packet, la memoria por consulta, y sobre todo que el volcado diario pase de 120 KB a varios gigas y la copia de seguridad deje de ser cómoda.

El truco que compensa el coste

Servir la imagen desde la base de datos cuesta más que servirla con nginx. Se compensa con una cabecera:

Cache-Control: private, max-age=31536000, immutable

immutable significa «esto no va a cambiar nunca, ni te molestes en revalidarlo». El navegador la pide una sola vez en su vida. private porque es contenido de un usuario concreto: que lo guarde su navegador, pero ningún proxy intermedio.

Con eso, el coste real es una petición por imagen y por dispositivo. Nada.

¿Aplica todo esto a los PDF?

La mitad sí y la mitad no, y la distinción es interesante.

Lo que aplica igual: base64 sigue costando un 33 % y BLOB un 0 %; y lo de la tabla aparte importa aún más, porque un PDF pesa megas, no kilobytes.

Lo que no aplica: recomprimir. Y aquí está la idea de fondo.

Un PDF no es un formato: es un contenedor, más parecido a un ZIP que a una foto. Dentro lleva objetos independientes y cada uno ya viene comprimido: los flujos de texto y los vectores con Flate (el mismo algoritmo que gzip), las fotos con DCT (que es JPEG), los escaneos en blanco y negro con CCITT o JBIG2.

Consecuencia práctica que sorprende a mucha gente: meter un PDF en un ZIP no sirve de nada. Ahorras un 2-5 %, porque estás comprimiendo algo ya comprimido. Con un BMP o un WAV ganarías muchísimo; con un PDF, no.

Entonces, ¿un PDF de 20 MB no se puede reducir?

Sí, pero por dentro. Y el peso casi siempre está en el mismo sitio: las imágenes incrustadas. Un PDF escaneado es literalmente una pila de JPEGs metidos en un contenedor. Si el escáner trabajó a 600 DPI, cada página es una foto enorme para algo que vas a leer en pantalla —exactamente el mismo error que guardar la miniatura a resolución de original—.

Y ahí vuelve, idéntico, el razonamiento de las miniaturas:

  • Bajar la resolución de las imágenes internas a 150–200 DPI. Suele ser el 90 % del ahorro.
  • Subconjuntar las fuentes: incrustar solo los glifos usados.
  • Deduplicar: la misma imagen repetida en 40 páginas, guardada una vez.
  • Linearizar (fast web view): reordenar el fichero para que la primera página se pinte antes de descargarlo entero.

Con ghostscript o qpdf, un escaneo de 20 MB se queda a menudo en 2. Mismo principio: no comprimas mejor, guarda menos.

La precaución que con las imágenes no existe

Con una miniatura puedo hacer lo que quiera: es decorativa y regenerable. Con un PDF, muchas veces no.

Si es una factura, un contrato o cualquier cosa con firma digital, recomprimirlo cambia los bytes y rompe la firma: deja de ser válido. En esos casos se guarda byte a byte, intacto, y si quieres una versión ligera para ver en pantalla, se guarda además, no en lugar de.

Imagen decorativa → optimiza sin miedo. Documento con valor probatorio → intocable.

El principio, si hay que quedarse con uno

Todo lo anterior es la misma idea aplicada tres veces:

No optimices el envase. Reduce el contenido.

Y coloca cada cosa según su tamaño y su temperatura: lo pequeño y caliente cerca, lo grande y frío lejos.

Apéndice: reprodúcelo tú

Este script mide las cuatro variantes sobre cualquier imagen. Sin él, el artículo son opiniones; con él, son datos.

import base64, io, requests
from PIL import Image

URL = "https://…"          # una imagen cualquiera
original = requests.get(URL, timeout=20).content
img = Image.open(io.BytesIO(original)); img.load()

def kb(b): return f"{len(b)/1024:8.1f} KB"

# El mismo contenido en PNG
buf = io.BytesIO(); img.convert("RGB").save(buf, format="PNG", optimize=True)
png = buf.getvalue()

# Reescalado + JPEG, que es lo que deberíamos guardar
ANCHO = 640
if img.width > ANCHO:
    img = img.resize((ANCHO, round(img.height * ANCHO / img.width)), Image.LANCZOS)
buf = io.BytesIO(); img.convert("RGB").save(buf, format="JPEG", quality=82, optimize=True)
jpg = buf.getvalue()

print("original          ", kb(original))
print("PNG               ", kb(png))
print("PNG en base64     ", kb(base64.b64encode(png)))
print("JPEG 640px q82    ", kb(jpg))

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio