¿Un microcontrolador de menos de diez mil pesos reproduciendo video? Suena imposible: el ESP32 no tiene decodificador de video por hardware, apenas unos 300 KB de RAM libre y ningún acelerador gráfico. Y sin embargo funciona, siempre que dejes de pensar en "video" y empieces a pensar en "muchas fotos seguidas".

En este tutorial vas a armar un reproductor que lee un archivo .mjpeg desde una tarjeta microSD y lo muestra a 15 cuadros por segundo en una pantalla TFT de 2.4 pulgadas. Al terminar vas a saber convertir cualquier video al formato correcto, compartir un mismo bus SPI entre la pantalla y la memoria, y dimensionar el buffer de RAM para que la reproducción no se corte a la mitad.

El truco está en el formato, no en el ESP32

La razón por la que un MP4 normal jamás va a correr acá es la compresión entre cuadros. H.264 guarda un cuadro completo cada tanto y, entre medio, solo las diferencias respecto a cuadros anteriores y posteriores. Para reconstruir un solo fotograma el decodificador necesita mantener varios cuadros de referencia en memoria y hacer cálculos de compensación de movimiento: decenas de megabytes de RAM y una potencia de cómputo que este chip no tiene.

MJPEG (Motion JPEG) resuelve el problema por el lado opuesto: cada cuadro es una imagen JPEG completa e independiente, pegada una detrás de la otra en un solo archivo. No hay referencias entre cuadros, no hay estado que mantener. El ESP32 solo tiene que encontrar dónde empieza y dónde termina cada JPEG, decodificar esa imagen sola y mandarla a la pantalla. Pagas el precio en tamaño de archivo. un MJPEG pesa bastante más que un MP4 equivalente. , pero a cambio la decodificación se vuelve algo que un microcontrolador puede hacer 15 veces por segundo.

Diagrama de bloques del reproductor de video con ESP32: tarjeta SD, buffer en RAM, decodificador JPEG y pantalla TFT

El flujo completo son cuatro etapas encadenadas:

  1. Tarjeta SD: guarda el archivo .mjpeg y el ESP32 lo lee de corrido por el bus SPI.
  2. Buffer en RAM: los datos entran en bloques y no byte por byte, para que la lectura no frene la reproducción. Ahí mismo se buscan las marcas de inicio y fin de cada cuadro.
  3. Decodificador JPEG: la librería JPEGDEC descomprime la imagen en memoria y la convierte a RGB565, el formato de 16 bits por pixel que entiende la pantalla.
  4. Pantalla TFT: recibe los pixeles por SPI y los dibuja. Un pequeño retardo al final de cada cuadro mantiene el ritmo parejo en 15 FPS.

Qué necesitas

  • Placa ESP32 DevKit V1. el cerebro. Sus dos núcleos y su SPI de alta velocidad son lo que hace viable el proyecto.
  • Pantalla TFT SPI 2.4" ILI9341 240x320 (TJCTM24024-SPI). importante: este módulo trae ranura microSD integrada en la parte de atrás. No necesitas un lector de tarjetas aparte.
  • Tarjeta microSD 8GB clase 10. donde vive el video. Formateada en FAT32.
  • Jumpers macho hembra 20cm. para el cableado entre la placa y el módulo.
  • Protoboard 400 puntos. opcional, pero ordena mucho el montaje.
  • Cable USB-A a micro USB 30cm. programación y alimentación.

Paso 1: convertir el video a MJPEG

Cualquier conversor web gratuito sirve para pasar de .mp4 a .mjpeg. Lo que no es opcional son los parámetros:

Ajuste Valor
Resolución 320x240
Formato MJPEG
FPS 15
Calidad Media

La resolución tiene que calzar con la de la pantalla (320x240 en horizontal). Si la subes, cada cuadro comprimido crece y en algún momento deja de caber en el buffer de RAM; si subes los FPS, el ESP32 no alcanza a decodificar y la reproducción se atrasa sola.

Copia el archivo convertido a la raíz de la microSD con el nombre video.mjpeg. Si prefieres otro nombre, tendrás que cambiarlo también en el código: la ruta aparece literal en dos líneas del sketch.

Paso 2: dos dispositivos, un solo bus SPI

Acá está la parte que más confunde a quien arma esto por primera vez. La pantalla y la tarjeta SD comparten los mismos tres cables de datos y reloj (MOSI, MISO, SCK) y se diferencian únicamente por su línea CS (Chip Select). Es el funcionamiento normal de SPI: el maestro baja el CS del dispositivo con el que quiere hablar y los demás ignoran el tráfico. Por eso verás GPIO 23, 19 y 18 repetidos en la tabla de conexiones. no es un error de tipeo.

Diagrama de conexiones entre la pantalla TFT y la placa ESP32 del reproductor de video

Pin de la pantalla TFT Pin del ESP32
VCC 3.3V
GND GND
CS GPIO 2
RST GPIO 4
D/C GPIO 5
MOSI GPIO 23
SCK GPIO 18
LED 3.3V
MISO GPIO 19
SD_CS GPIO 15
SD_MOSI GPIO 23
SD_MISO GPIO 19
SD_SCK GPIO 18

Alimenta la pantalla solo con 3.3V. El controlador ILI9341 no es tolerante a 5V y conectarlo al pin VIN es la forma más rápida de arruinar el módulo.

Paso 3: instalar las librerías

Desde el gestor de librerías del IDE de Arduino necesitas tres:

  • Adafruit ILI9341. el driver de la pantalla.
  • Adafruit GFX Library. las primitivas de dibujo de las que depende la anterior.
  • JPEGDEC. el decodificador de Larry Bank, escrito específicamente para microcontroladores sin punto flotante.

Paso 4: el código

C++
#include <SPI.h>
#include <SD.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <JPEGDEC.h>
// --- TFT Display Pins ---
#define TFT_CS   2
#define TFT_DC   5
#define TFT_RST  4
// --- SD Card Pin ---
#define SD_CS    15
// --- Frame buffer for one JPEG frame ---
// Increase if your frames are larger than this; watch ESP32 RAM limits.
#define FRAME_BUF_SIZE (80 * 1024)
static uint8_t *frameBuf = nullptr;
// --- Target playback rate ---
#define TARGET_FPS 15
#define FRAME_INTERVAL_MS (1000 / TARGET_FPS)
Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC, TFT_RST);
JPEGDEC jpeg;
File videoFile;
// Drawing callback function
int JPEGDraw(JPEGDRAW *pDraw) {
 tft.drawRGBBitmap(pDraw->x, pDraw->y, pDraw->pPixels, pDraw->iWidth, pDraw->iHeight);
 return 1;
}
// Reads one JPEG frame (SOI 0xFFD8 ... EOI 0xFFD9) from videoFile into frameBuf.
// Returns frame size in bytes, or 0 if no more frames / error.
size_t readNextFrame() {
 // 1. Find SOI marker (0xFF 0xD8)
 int b1 = -1, b2 = -1;
 bool foundSOI = false;
 while (videoFile.available() >= 2) {
   b1 = videoFile.read();
   if (b1 == 0xFF) {
     b2 = videoFile.peek();
     if (b2 == 0xD8) {
       videoFile.read(); // consume the 0xD8
       foundSOI = true;
       break;
     }
   }
 }
 if (!foundSOI) return 0; // EOF reached without finding a new frame
 frameBuf[0] = 0xFF;
 frameBuf[1] = 0xD8;
 size_t idx = 2;
 // 2. Read bytes until EOI marker (0xFF 0xD9) is found
 int prevByte = 0;
 while (videoFile.available() && idx < FRAME_BUF_SIZE) {
   int curByte = videoFile.read();
   frameBuf[idx++] = (uint8_t)curByte;
   if (prevByte == 0xFF && curByte == 0xD9) {
     return idx; // complete frame captured
   }
   prevByte = curByte;
 }
 // Ran out of buffer space or file ended mid-frame
 Serial.print("WARN: frame incomplete or buffer too small, got ");
 Serial.print(idx);
 Serial.println(" bytes before running out of buffer/file");
 return 0;
}
void setup() {
 Serial.begin(115200);
 delay(1000);
 Serial.println("\n--- ESP32 Video Player Initializing ---");
 frameBuf = (uint8_t *)malloc(FRAME_BUF_SIZE);
 if (!frameBuf) {
   Serial.println("ERROR: Could not allocate frame buffer! Reduce FRAME_BUF_SIZE.");
   while (1) delay(1000);
 }
 Serial.print("Free heap after buffer alloc: ");
 Serial.println(ESP.getFreeHeap());
 tft.begin(27000000);
 tft.setRotation(1); // Landscape orientation
 tft.fillScreen(ILI9341_BLACK);
 Serial.println("Display Initialized.");
 if (!SD.begin(SD_CS)) {
   Serial.println("ERROR: SD Card initialization failed!");
   while (1) delay(1000);
 }
 Serial.println("SD Card initialized successfully!");
 if (!SD.exists("/video.mjpeg")) {
   Serial.println("ERROR: /video.mjpeg not found on SD card!");
   while (1) delay(1000);
 }
 videoFile = SD.open("/video.mjpeg", FILE_READ);
 if (!videoFile) {
   Serial.println("ERROR: Could not open /video.mjpeg!");
   while (1) delay(1000);
 }
 Serial.println("Found and opened /video.mjpeg on SD card!");
}
void loop() {
 unsigned long frameStart = millis();
 size_t frameSize = readNextFrame();
 if (frameSize == 0) {
   // End of file (or bad frame) — loop the video back to the start
   Serial.println("End of video, looping...");
   videoFile.seek(0);
   return;
 }
 if (jpeg.openRAM(frameBuf, frameSize, JPEGDraw)) {
   jpeg.decode(0, 0, 0);
   jpeg.close();
 } else {
   Serial.print("ERROR: jpeg.openRAM failed on this frame, size=");
   Serial.println(frameSize);
 }
 // Pace playback to target FPS
 unsigned long elapsed = millis() - frameStart;
 if (elapsed < FRAME_INTERVAL_MS) {
   delay(FRAME_INTERVAL_MS - elapsed);
 }
}

Cómo funciona por dentro

Vale la pena mirar dos cosas que el código hace y que no son obvias.

La función readNextFrame() busca marcadores a mano. Un archivo MJPEG no tiene índice ni tabla de cuadros: es una tira continua de bytes. Cada imagen JPEG empieza con la marca SOI (0xFF 0xD8) y termina con la marca EOI (0xFF 0xD9). La función recorre el archivo hasta encontrar un SOI, copia todo al buffer y se detiene apenas ve un EOI. Ese par de bytes es lo único que separa un cuadro del siguiente.

El buffer de 80 KB es el límite real del proyecto. FRAME_BUF_SIZE reserva 80 KB con malloc() para alojar un cuadro comprimido completo. Si un cuadro de tu video pesa más que eso, el sketch te avisa por el monitor serial ("frame incomplete or buffer too small") y la reproducción se corta. Puedes subir el número, pero tienes poco margen: un ESP32 común ronda los 300 KB de heap libre. Si necesitas cuadros más grandes, la salida es una placa con PSRAM (ESP32-WROVER), donde ese buffer puede irse a varios megabytes usando ps_malloc().

Un detalle que conviene saber: aunque a este tipo de montaje se lo describe corriendo a 40 MHz, el sketch inicializa la pantalla con tft.begin(27000000), es decir 27 MHz. Es un valor conservador y estable. Si tu cableado es corto y prolijo puedes probar subirlo; si aparecen pixeles con basura o franjas de color, bájalo de nuevo. los jumpers largos no toleran bien el reloj alto.

Pruebas y problemas comunes

Con el código cargado, abre el monitor serial a 115200 baudios. La secuencia esperada es "Display Initialized" → "SD Card initialized successfully!" → "Found and opened /video.mjpeg". Si algo se detiene antes:

  • "Could not allocate frame buffer": no hay heap suficiente. Baja FRAME_BUF_SIZE a 60 KB o usa una placa con PSRAM.
  • "SD Card initialization failed": casi siempre es la tarjeta. Formatéala en FAT32 (no exFAT) y revisa que SD_CS esté firme en GPIO 15. Las tarjetas de mucha capacidad suelen dar problemas; una de 8 o 16 GB es más segura.
  • "/video.mjpeg not found": el archivo tiene que estar en la raíz y en minúsculas, exactamente con ese nombre.
  • Pantalla blanca o en negro pero sin errores: revisa el pin LED de la retroiluminación y el orden de RST y D/C, que se confunden con facilidad.
  • Reproducción a tirones: tu video probablemente quedó con más FPS o más resolución de lo indicado. Vuelve a convertirlo con los valores de la tabla.

Variantes y mejoras

  • Carcasa impresa en 3D: con un soporte impreso que encaje la pantalla y la placa por detrás, el montaje deja de ser un experimento en protoboard y se convierte en un marco de video de escritorio. Un modelo simple de dos piezas (frente con ventana de 320x240 y tapa con calado para el micro USB) se imprime en menos de dos horas en PLA.
  • Cargar videos por WiFi: el ESP32 tiene WiFi, así que no necesitas sacar la tarjeta cada vez que quieras cambiar el contenido. Levantando un servidor web con ESPAsyncWebServer puedes subir archivos .mjpeg desde el navegador directo a la microSD. Ojo con el bus SPI: conviene detener la reproducción mientras se escribe la tarjeta.
  • Playlist con varios videos: en vez de abrir /video.mjpeg fijo, recorre el directorio raíz con SD.open("/") y openNextFile(), guarda los nombres que terminen en .mjpeg y cambia de archivo cuando readNextFrame() devuelva 0. Con un botón en un GPIO libre tienes un selector de video.
  • Sonido: MJPEG no lleva audio, pero puedes guardar un .wav aparte y reproducirlo por el DAC del ESP32 en el segundo núcleo, sincronizando por número de cuadro.

Personalización para Chile

Todos los componentes de este proyecto son parte del catálogo habitual de MechatronicStore y los encuentras con estos nombres:

  • Placa ESP32 DevKit V1. el original habla de un "ESP32 Dev Module" genérico; la DevKit V1 es exactamente esa placa y es la más común acá.
  • Pantalla TFT SPI 2.4" ILI9341 240x320. busca la variante con lector microSD integrado (TJCTM24024-SPI). Es la que permite hacer el proyecto sin comprar un módulo lector aparte.
  • Tarjeta microSD 8GB clase 10. con 8 GB te sobra: un video MJPEG de 320x240 a 15 FPS ocupa del orden de 1 GB por hora.
  • Jumpers macho hembra 20cm y Protoboard 400 puntos. para el montaje.
  • Cable USB-A a micro USB 30cm. para programar y alimentar.

Si en algún tutorial en inglés ves un "SparkFun ESP32 Thing" o una "Adafruit HUZZAH32", la DevKit V1 cumple la misma función a una fracción del precio: el chip es el mismo ESP32-WROOM-32 y los GPIO del código no cambian.

Recursos

Contenido inspirado en el tutorial original de Circuit Digest, reescrito y ampliado con explicación del formato MJPEG, del bus SPI compartido y de los errores más frecuentes al armarlo. Versión chilena con componentes en stock local en MechatronicStore.