←Blog

Un reloj que corre dentro de un correo — un GIF que arma el servidor

El correo trae una sola imagen, y el servidor la arma en el momento en que se abre: una cuenta regresiva real en ~27 ms, con un codificador GIF propio y cuadros parciales.

10 min de lecturaFixter
  • correo
  • GIF
  • email HTML
  • cuenta regresiva
  • rendimiento

El correo del ensayo de nuestro micro-hackatón trae una cuenta regresiva que corre de verdad. Cada vez que alguien abre el correo, el reloj enseña los días, las horas, los minutos y los segundos que faltan desde ese instante, y los segundos bajan uno por uno.

Este es el reloj, en vivo. Es la misma imagen que recibe el correo, servida por la misma ruta:

Cuenta regresiva viva al ensayo del micro-hackatón: días, horas, minutos y segundos que bajan cada segundo

Recarga esta página y el reloj vuelve a empezar desde la hora de ahora. Cuando el ensayo empiece, la misma imagen dirá «¡ENTRA YA!», y cuando termine dirá «¡NOS VEMOS EL JUEVES!».

Un correo no corre JavaScript, pero sí pide una imagen al abrirse

Gmail, Outlook y Apple Mail quitan todo <script> de un correo. Lo que sí hacen todos es pedir las imágenes al servidor cuando la persona abre el mensaje. El truco entero vive en esa petición: el correo trae un <img> normal y el servidor contesta con un GIF animado armado en ese momento.

El HTML del correo es HTML de correo de toda la vida: tablas, estilos en línea y la imagen con ancho fijo.

html
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">  <tr>    <td align="center" style="padding:0 0 18px">      <a href="https://www.ghosty.studio/hackaton">        <img          src="https://www.ghosty.studio/hackaton/ensayo.gif?r=3f9a1c07b2e4"          width="552"          alt="Ensayo opcional: martes 10, 7 pm"          style="display:block;width:100%;max-width:552px;height:auto;border:0"        >      </a>    </td>  </tr></table>

El ?r= lleva una clave distinta por destinatario, sacada de un hash de su correo. Gmail guarda cada imagen en su proxy la primera vez que se abre el mensaje y la sirve desde ahí después; con una URL por persona, el reloj de una persona nunca le llega a otra. La ruta contesta con cache-control: no-store para que ningún navegador ni proxy guarde el reloj.

ts
// La imagen viva del correo: cada destinatario lleva su ?r= y la respuesta no se guarda en caché.export async function loader() {  const { liveCardImage } = await import("../lib/hackathon/live-clock.server");  const img = await liveCardImage(CURRENT_HACKATHON, new Date());  if ("redirect" in img) return Response.redirect(img.redirect, 302); // respaldo fijo si algo falla  return new Response(new Uint8Array(img.body), {    headers: {      "content-type": img.type,      "cache-control": "no-store, no-cache, must-revalidate, max-age=0",      pragma: "no-cache",      expires: "0",    },  });}

La primera versión tardaba 5 segundos en cada apertura

La primera versión armaba el reloj con sharp cuadro por cuadro: pegaba los números sobre el fondo, convertía cada cuadro a GIF y juntaba 60 cuadros. En la caja de producción eso tardaba ~5 s por apertura. Un correo que tarda 5 segundos en enseñar su imagen parece un correo roto.

El problema estaba en repetir 60 veces un trabajo que casi no cambia. Entre un segundo y el siguiente, en el reloj sólo cambian los dos dígitos de los segundos. El resto de la tarjeta de 1200 × 360 pixeles queda idéntico.

El formato GIF permite mandar sólo el pedazo que cambió

La especificación GIF89a tiene dos piezas que resuelven esto desde 1990:

  1. Cada cuadro tiene posición y tamaño propios. El descriptor de imagen trae izquierda, arriba, ancho y alto. Un cuadro puede ser un rectángulo de 160 × 112 pixeles encima de la casilla de los segundos.
  2. El método de desecho 1 deja el cuadro en su lugar. Con «no desechar», el siguiente cuadro se pinta encima del anterior. Los parches se apilan y el fondo nunca se vuelve a mandar.

Con esas dos piezas, el reloj queda así: el cuadro 0 es la tarjeta completa con la hora correcta, y cada cuadro siguiente es un parche con las casillas que cambiaron. En un segundo normal, el parche es la casilla de los segundos. Cuando los segundos pasan de 00 a 59, el parche se extiende hasta la casilla de los minutos.

El trabajo caro se hace una vez por proceso

Lo que tarda es la cuantización: bajar una imagen de millones de colores a una paleta de GIF. Ese trabajo depende sólo del diseño, así que se hace una vez cuando el proceso arranca:

  1. Se bajan dos imágenes pre-renderizadas con Chrome headless: el fondo de la tarjeta con las casillas vacías y una hoja de sprites con los números «00» a «99».
  2. Se pegan las 100 casillas debajo del fondo, en una sola imagen alta.
  3. sharp cuantiza esa imagen completa a 64 colores, sin tramado. Al cuantizar todo junto, el fondo y los 100 números comparten la misma paleta.
  4. Cada pixel se guarda como índice de esa paleta: un arreglo de bytes para el fondo y uno para cada número.

Esa preparación tarda ~1.5 s en frío, una vez por proceso. A partir de ahí, cada apertura sólo copia bytes y comprime.

ts
// Por apertura: cuadro 0 completo y luego un parche por segundo con las casillas que cambiaron.export async function clockGif(msLeft: number): Promise<Uint8Array> {  const { palette, bg, patches } = await clockKit(); // índices ya cuantizados, una vez por proceso  const W = LIVE_CARD_W, bw = CLOCK.w, bh = CLOCK.h;  const screen = bg.slice();  const paste = (box: number, n: number) => {    const x0 = clockBoxX(box);    for (let y = 0; y < bh; y++) screen.set(patches[n].subarray(y * bw, (y + 1) * bw), (CLOCK.top + y) * W + x0);  };
  const frames: GifFrame[] = [];  let prev = clockParts(msLeft); // [días, horas, minutos, segundos]  prev.forEach((n, k) => paste(k, n));  // Cuadro 0 completo: es lo único que enseña Outlook en Windows, así que lleva la hora correcta.  frames.push({ x: 0, y: 0, width: W, height: LIVE_CARD_H, indices: screen.slice(), delayCs: 100 });
  for (let i = 1; i < CLOCK_FRAMES; i++) {    const parts = clockParts(msLeft - i * 1000);    const first = parts.findIndex((n, k) => n !== prev[k]);    prev = parts;    if (first < 0) continue; // ya llegó a 00:00:00:00    for (let k = first; k < 4; k++) paste(k, parts[k]);    // Parche contiguo desde la primera casilla que cambió hasta la de segundos.    const x = clockBoxX(first), width = clockBoxX(3) + bw - x;    const rect = new Uint8Array(width * bh);    for (let y = 0; y < bh; y++) rect.set(screen.subarray((CLOCK.top + y) * W + x, (CLOCK.top + y) * W + x + width), y * width);    frames.push({ x, y: CLOCK.top, width, height: bh, indices: rect, delayCs: 100 });  }  return encodeGif({ width: W, height: LIVE_CARD_H, palette, frames }); // sin loop}

El codificador GIF cabe en 90 líneas y no tiene dependencias

sharp no sabe escribir cuadros parciales, así que escribimos el codificador. Hace tres cosas: escribe la cabecera con una paleta global, escribe cada cuadro con su control gráfico y su rectángulo, y comprime los índices con LZW. El LZW es el mismo algoritmo que usa omggif: códigos de 3 a 12 bits y un código CLEAR cuando la tabla llega a 4096 entradas.

ts
// Codificador GIF89a mínimo, sin dependencias: paleta global fija y cuadros que pueden ser un PARCHE// (x, y, ancho, alto) encima del anterior.// LZW de GIF (mismo algoritmo que omggif, MIT): códigos de 3 a 12 bits, CLEAR al llenarse la tabla.
export type GifFrame = { x: number; y: number; width: number; height: number; indices: Uint8Array; delayCs: number };
/** `palette`: colores [r, g, b] (≤ 256). `loop`: false = una sola pasada (sin extensión NETSCAPE). */export function encodeGif(o: { width: number; height: number; palette: [number, number, number][]; frames: GifFrame[]; loop?: boolean }): Uint8Array {  const bits = Math.max(2, Math.ceil(Math.log2(Math.max(2, o.palette.length))));  const tableSize = 1 << bits;  const out: number[] = [];  const u16 = (n: number) => out.push(n & 0xff, (n >> 8) & 0xff);  for (const c of "GIF89a") out.push(c.charCodeAt(0));  u16(o.width);  u16(o.height);  out.push(0x80 | 0x70 | (bits - 1), 0, 0); // tabla global, 8 bits por color, tamaño 2^bits  for (let i = 0; i < tableSize; i++) {    const c = o.palette[i] ?? [0, 0, 0];    out.push(c[0], c[1], c[2]);  }  if (o.loop) out.push(0x21, 0xff, 0x0b, ..."NETSCAPE2.0".split("").map((c) => c.charCodeAt(0)), 0x03, 0x01, 0, 0, 0);  for (const f of o.frames) {    // Control gráfico: disposal 1 = el cuadro se queda debajo del siguiente (los parches se apilan).    out.push(0x21, 0xf9, 0x04, 1 << 2);    u16(f.delayCs);    out.push(0, 0);    // Descriptor de imagen: posición y tamaño del parche.    out.push(0x2c);    u16(f.x);    u16(f.y);    u16(f.width);    u16(f.height);    out.push(0);    out.push(bits);    const data = lzw(f.indices, bits);    // Los datos van en bloques de hasta 255 bytes.    for (let i = 0; i < data.length; i += 255) {      const n = Math.min(255, data.length - i);      out.push(n);      for (let j = 0; j < n; j++) out.push(data[i + j]);    }    out.push(0);  }  out.push(0x3b);  return Uint8Array.from(out);}
function lzw(indices: Uint8Array, minCodeSize: number): number[] {  const out: number[] = [];  const clear = 1 << minCodeSize;  const eoi = clear + 1;  let codeSize = minCodeSize + 1;  let next = eoi + 1;  let table = new Map<number, number>();  let cur = 0;  let shift = 0;  const emit = (code: number) => {    cur |= code << shift;    shift += codeSize;    while (shift >= 8) {      out.push(cur & 0xff);      cur >>= 8;      shift -= 8;    }  };  emit(clear);  let prefix = indices[0];  for (let i = 1; i < indices.length; i++) {    const k = indices[i];    const key = (prefix << 8) | k;    const hit = table.get(key);    if (hit !== undefined) {      prefix = hit;      continue;    }    emit(prefix);    if (next === 4096) {      emit(clear);      next = eoi + 1;      codeSize = minCodeSize + 1;      table = new Map();    } else {      if (next >= 1 << codeSize) codeSize++;      table.set(key, next++);    }    prefix = k;  }  emit(prefix);  emit(eoi);  if (shift > 0) out.push(cur & 0xff);  return out;}

De 5 segundos a 27 milisegundos, medido en producción

MediciónResultado
sharp cuadro por cuadro, 60 cuadros~5 s por apertura
Parches, en caliente~27 ms por apertura
Parches, en frío (una vez por proceso)~1.5 s
Costo de cada segundo de reloj~1.6 KB
60 cuadros (1 minuto)132 KB
300 cuadros (5 minutos)~520 KB, ~1.4 s de descarga en producción

Con 1.6 KB por segundo, alargar el reloj salió barato. El GIF final dura 5 minutos: 300 cuadros, ~520 KB. Nadie mira un correo abierto 5 minutos, y si alguien lo reabre, el servidor arma otro reloj desde la hora nueva.

El reloj corre una sola vez y Outlook ve sólo el primer cuadro

Dos decisiones salieron de cómo se comportan los clientes de correo:

  • El GIF no hace loop. Un GIF en loop vuelve al cuadro 0 al terminar, y un reloj que vuelve al cuadro 0 «sube» 5 minutos de golpe. Sin la extensión NETSCAPE, el GIF corre una vez y se queda en el último segundo.
  • El cuadro 0 va completo y con la hora correcta. Outlook en Windows no reproduce GIF animados y enseña sólo el primer cuadro. Para esa persona, el reloj es una foto de la cuenta regresiva en el momento en que abrió el correo, y la foto dice la verdad.

Si algo falla al armar el GIF, la ruta redirige a una imagen fija con la fecha del ensayo. El correo nunca enseña una imagen rota.

En resumen

Un correo no corre código, pero pide sus imágenes al abrirse. Una ruta que arma un GIF en ese instante convierte el <img> en una cuenta regresiva real. La versión rápida cuantiza todo una vez por proceso y, por apertura, manda el cuadro 0 completo más un parche de 160 × 112 pixeles por segundo. Con eso, armar 5 minutos de reloj tarda ~27 ms y pesa ~520 KB, y el mismo <img> cambia a «¡ENTRA YA!» cuando empieza el ensayo sin mandar un segundo correo.

@brendi & @blissito, creadores de Ghosty

Fuentes

Pruébalo gratis hoy

Sin código. Sin complicaciones. Crea tu agente y ponlo a trabajar en minutos.

Ghosty ® 2026. Todos los derechos reservados.