Transformar en el Worker: redimensionar y recortar al vuelo
El motor de optimización de imágenes no exige almacenarlas en Cloudflare: se invoca como una opción más de una subpetición desde tu Worker. Vemos la propiedad cf.image en fetch y por qué convierte al Worker en el sitio donde se decide el tamaño, el ajuste, el recorte y la calidad de cada imagen; cómo se negocia el formato leyendo la cabecera Accept a mano; qué compran los modos de ajuste y la gravedad del recorte; y cuándo conviene el binding de Images, que trabaja sobre bytes en vez de sobre URL y por tanto alcanza orígenes privados a los que ninguna URL pública llega.
La lección anterior servía imágenes que Cloudflare guardaba. Esta trata del caso mucho más común: las imágenes ya viven en algún sitio tuyo —un bucket de R2, un servidor heredado, un proveedor del que no piensas moverte— y lo único que quieres es que lleguen al navegador con el tamaño correcto y en un formato de este siglo. Para eso no hace falta migrar nada. El motor de optimización se invoca como una opción de una subpetición ordinaria, lo que significa que el Worker deja de ser un intermediario que reenvía bytes y pasa a ser el sitio donde se decide, por petición y con toda tu lógica delante, qué imagen exactamente merece este visitante.
- Invocar el motor de optimización desde
fetchcon la propiedadcf.imagey saber dónde está disponible. - Elegir el modo de ajuste y la gravedad del recorte según lo que cada hueco de la interfaz exige.
- Negociar el formato de salida leyendo la cabecera
Accepta mano dentro del Worker. - Distinguir cuándo conviene el binding de
Images, que opera sobre bytes en lugar de sobre URL.
La transformación como opción de una subpetición
El mecanismo es de una sencillez que desarma. Cuando pides una imagen con fetch desde un Worker, el segundo argumento admite un objeto cf con opciones específicas de la plataforma, y dentro de él una clave image describe la transformación que quieres aplicar mientras la respuesta pasa por el edge. No hay servicio intermedio, no hay cola, no hay fichero temporal: es la misma subpetición de siempre con una descripción del resultado.
export default {
async fetch(request: Request): Promise<Response> {
const original = "https://origen.ejemplo.com/fotos/a-142.jpg";
return fetch(original, {
cf: {
image: { fit: "scale-down", width: 800, height: 600, quality: 82 },
},
});
},
};
Tres detalles gobiernan el despliegue. El primero es que cf.image está disponible en cualquier zona que aloje un Worker, incluidos los subdominios de workers.dev, pero la zona debe tener activadas las transformaciones. El segundo es que cada transformación se factura a la cuenta dueña del Worker, no a la del origen, lo cual importa cuando sirves imágenes de terceros. Y el tercero es el que más disgustos evita: el objeto cf solo tiene efecto en subpeticiones salientes, así que devolver la respuesta de ese fetch es lo que funciona, no adornar la petición entrante.
Existe una segunda cara del mismo motor, la interfaz por URL, con un prefijo fijo /cdn-cgi/image/ seguido de las opciones y del origen. Es más rápida de probar desde la barra del navegador y no requiere escribir código, pero expone la ruta real de la imagen y deja el catálogo de transformaciones abierto a quien sepa componer una dirección. El Worker existe precisamente para cerrar eso: tú decides el esquema de URL que el mundo ve, y el origen real puede ser un bucket privado cuya dirección nunca sale al exterior.
Si tu ruta pública es un identificador y un nombre de preajuste, el conjunto de imágenes que tu cuenta puede llegar a generar es finito y lo defines tú. Si en cambio aceptas anchos arbitrarios de la cadena de consulta, cualquiera puede recorrer mil anchos consecutivos y convertir cada uno en una transformación facturable que nadie volverá a pedir. Valida contra una lista cerrada de preajustes antes de tocar el motor.
Ajuste, gravedad y calidad
Redimensionar es trivial; recortar bien no lo es. Cuando la proporción del original no coincide con la del hueco hay que sacrificar algo, y las opciones del motor son precisamente la enumeración de qué se sacrifica. Los cinco modos de ajuste —scale-down, contain, cover, crop y pad— ya aparecieron en la lección anterior, y aquí funcionan idénticos porque es el mismo motor. Lo nuevo es lo que solo tiene sentido cuando recortas.
Con cover o con crop hay que decidir qué parte de la imagen sobrevive. Por defecto se conserva el centro geométrico, que es una apuesta razonable y frecuentemente equivocada: en un retrato vertical llevado a una tira apaisada, el centro suele ser el torso. La opción de gravedad acepta un lado, unas coordenadas relativas o dos valores automáticos que resuelven el caso real —auto, que busca la región con más detalle, y face, que busca caras—.
const opciones = {
cf: {
image: {
fit: "cover",
width: 320,
height: 320,
gravity: "face",
quality: "medium-high",
metadata: "none",
},
},
} satisfies RequestInit;
La calidad admite dos vocabularios. El numérico va de uno a cien con ochenta y cinco por defecto, y es el que conoces de cualquier compresor. El perceptual —high, medium-high, medium-low, low— es más interesante porque no fija un número sino un objetivo de calidad aparente, y deja que el motor gaste más bits en una fotografía con textura fina y menos en una ilustración plana. En un catálogo heterogéneo suele dar mejores resultados que cualquier número que elijas a mano.
Dos opciones más merecen sitio en el vocabulario. Los dibujos superpuestos, disponibles solo desde Workers, aceptan una lista de imágenes con posición, ajuste y opacidad, y son la vía canónica para marcas de agua, logotipos y viñetas sin generar variantes nuevas. Y el formato especial de salida que devuelve un objeto de datos en vez de una imagen es la herramienta de diagnóstico que casi nadie conoce: te dice dimensiones antes y después, tipo original y tamaño en bytes, y sirve para instrumentar el ahorro real que estás consiguiendo.
| Opción | Valores útiles | Para qué sirve de verdad |
|---|---|---|
fit |
scale-down | cover | pad |
Decidir qué se sacrifica: nitidez, encuadre o espacio |
gravity |
auto | face | un lado |
Salvar el sujeto cuando el recorte es agresivo |
quality |
1 a 100 | high | low |
Cambiar peso por nitidez, fija o percibida |
metadata |
none | copyright | keep |
Eliminar coordenadas y datos de cámara del original |
draw |
Lista de superposiciones | Marcas de agua y logotipos sin variantes nuevas |
Negociar el formato a mano
Aquí está la diferencia práctica más importante entre las dos interfaces. Cuando sirves una imagen alojada por su URL de entrega, la negociación de formato viene resuelta. Cuando transformas desde un Worker, la responsabilidad es tuya: el valor automático de formato no se aplica solo, porque quien conoce las capacidades del cliente es tu código, que tiene la petición entrante en la mano. La cabecera Accept que manda el navegador es la declaración de qué sabe descodificar, y traducirla a una opción de formato son cuatro líneas.
export default {
async fetch(request: Request): Promise<Response> {
const acepta = request.headers.get("Accept") ?? "";
const image: Record<string, unknown> = { fit: "scale-down", width: 1200 };
if (acepta.includes("image/avif")) image.format = "avif";
else if (acepta.includes("image/webp")) image.format = "webp";
const url = new URL(request.url);
const origen = `https://origen.ejemplo.com${url.pathname}`;
const respuesta = await fetch(origen, { cf: { image } });
const salida = new Response(respuesta.body, respuesta);
salida.headers.append("Vary", "Accept");
return salida;
},
};
Fíjate en la última línea, que es la que separa un ejemplo de juguete de algo que puedes desplegar. Si una misma dirección devuelve bytes distintos según una cabecera de la petición, cualquier caché intermedia necesita saberlo o servirá AVIF a un cliente que solo entiende JPEG. Declarar esa dependencia con la cabecera Vary no es un detalle de purista: es la condición para que el resultado sea correcto a través de proxies que no controlas.
sequenceDiagram participant N as navegador participant W as worker en el edge participant O as origen privado N->>W: GET /fotos/a-142 con cabecera accept W->>W: valida el preajuste y elige formato W->>O: subpeticion con opciones de imagen O-->>W: bytes originales W-->>N: imagen redimensionada y recodificada Note over W: la ruta real del origen nunca sale
El binding: bytes en lugar de URL
Todo lo anterior parte de una premisa que a veces no se cumple: que la imagen es alcanzable por una URL que el motor pueda pedir. Cuando el original vive en un bucket privado de R2, o llega en el cuerpo de una petición de subida, o lo acaba de generar un modelo, no hay dirección pública que ofrecer. Para ese caso existe el binding de Images, que opera sobre un flujo de bytes y encadena operaciones hasta producir una salida.
{
"images": { "binding": "IMAGES" },
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "originales" }]
}
const objeto = await env.BUCKET.get("originales/a-142.jpg");
if (!objeto) return new Response("No existe", { status: 404 });
const salida = await env.IMAGES.input(objeto.body)
.transform({ width: 640 })
.transform({ blur: 12 })
.output({ format: "image/avif", quality: 80 });
return salida.response();
Las diferencias con la subpetición son tres y todas importan. La entrada admite bytes de cualquier procedencia hasta un tamaño máximo del orden de veinte megabytes, así que sirve para subidas y para orígenes privados por igual. El formato de salida es obligatorio: aquí no hay negociación implícita porque no hay petición entrante que interrogar, y eres tú quien decide. Y el encadenamiento de transformaciones es ordenado, lo que permite componer secuencias —rotar, luego recortar, luego difuminar— que la interfaz por URL no expresa. Hay además un método de inspección que devuelve dimensiones y formato sin producir imagen y sin facturarse, útil para rechazar una subida antes de gastar nada en ella.
Subpetición con opciones
El origen es una URL y el resultado se cachea en el edge. Es el camino por defecto para servir un catálogo público.
Binding sobre bytes
El origen es un flujo. Alcanza buckets privados y cuerpos de petición, y encadena operaciones en el orden que tú fijas.
Formato negociado a mano
En el Worker, la cabecera Accept la lees tú. Y si el resultado varía con ella, tienes que declararlo con Vary.
Cardinalidad, no volumen
Se factura por combinación distinta de origen y parámetros. Una lista cerrada de preajustes es el control de coste.
Merece la pena detenerse en lo que se ha disuelto, porque no es un servicio: es una clase entera de arquitectura. El patrón clásico para servir imágenes bien era un sistema asíncrono con estado —se sube el original, se encola un trabajo, un proceso genera las derivadas, se guardan, se registran en una tabla, y a partir de ahí la aplicación consulta esa tabla para saber qué URL escribir—. Ese diseño arrastra todos los problemas de la coordinación diferida: derivadas que no se generaron porque el trabajo falló en silencio, tablas que se desincronizan del almacenamiento, reprocesados masivos cada vez que alguien cambia un tamaño, y el estado intermedio más incómodo de todos, el de la imagen que ya existe pero cuyas miniaturas todavía no. Poner la transformación en el camino de la petición no lo optimiza: lo elimina. Ya no hay trabajos, ni cola, ni tabla de derivadas, ni ventana de inconsistencia, porque no hay nada precalculado que pueda faltar. Lo que queda es una función determinista —dado un original y unos parámetros, estos bytes— y una caché delante que hace que el coste real de esa función se pague una vez y se amortice sobre millones de lecturas. Esta es la misma sustitución que la industria lleva veinte años haciendo en otros frentes: el sitio estático generado por lotes cedió ante el renderizado con caché, la vista materializada nocturna cedió ante la consulta cacheada, y el ahorro no vino de calcular más deprisa sino de dejar de calcular por adelantado combinaciones que quizá nadie pediría. Pero conviene ver también qué se paga a cambio, porque no es gratis y el cambio de moneda es lo que hay que interiorizar. Un pipeline por lotes tiene coste proporcional al catálogo, es predecible y se paga de noche. Una transformación bajo demanda tiene coste proporcional a la variedad de lo que se pide, es impredecible si dejas el espacio de parámetros abierto, y se paga en el peor momento posible: mientras un visitante espera. Por eso la única disciplina que de verdad hace falta aquí no es aprender opciones, sino cerrar la puerta de la cardinalidad —una lista finita de preajustes validada en el Worker— y aceptar que el primer visitante de cada combinación paga el cálculo por todos los demás. El ingeniero que entiende esto deja de razonar sobre ficheros generados y empieza a razonar sobre una función y su tasa de acierto en caché, que es donde viven a la vez la latencia, la factura y la corrección.
- Escribe un Worker que sirva
/img/PREAJUSTE/CLAVEy traduzca el preajuste a opciones de imagen contra una lista cerrada, devolviendo400ante cualquier nombre desconocido. - Añade negociación de formato leyendo
Accepty comprueba con dos navegadores que el tipo de contenido cambia. Verifica que declarasVaryen la respuesta. - Toma un retrato vertical y llévalo a una tira apaisada con
fiten modo de relleno. Compara el resultado con gravedad por defecto, por caras y por un lado. - Guarda un original en un bucket privado de R2 y sírvelo con el binding sin exponer ninguna URL del bucket. Explica qué ataque cierra esa decisión.
- Usa el formato de salida de diagnóstico para medir el ahorro real en bytes de tus diez imágenes más pesadas, y decide si tu calidad por defecto está bien elegida.