September 06 / 2026 · Day 06

A Movie

Play a movie from raw frames — then make it longer than the board can hold, with an SD card.

ESP-IDF v5.5 + Waveshare BSP (LVGL 9.5)10 fps flash playback observed over serial; SD path unverified
On this page
Illustration of the Waveshare Touch AMOLED 1.8 development kit

Used in this project

Waveshare Touch AMOLED 1.8

ESP32-S3-Touch-AMOLED-1.8 V2

1.8″ AMOLED · 368 × 448 · ESP32-S3

Run this project

Firmware for ESP32-S3-Touch-AMOLED-1.8 V2

Download firmware ↓
Or flash from your browser

Connect the ESP32-S3-Touch-AMOLED-1.8 V2 with a USB data cable. Use a desktop Chromium browser with Web Serial support. Installation replaces the firmware on the connected board.

This browser does not support Web Serial. Use the download and terminal instructions below.

The result

Play a movie on the board. Then make it longer than the board can hold.

The player draws raw RGB565 frames — 368 × 224, ten per second, no audio — read one at a time from a dedicated flash partition, or from a microSD card when one is present. The computer does the conversion; the board only reads and draws. The included clip is three seconds of Big Buck Bunny, looping. Each day is a standalone firmware image; you can start here without flashing earlier days.

What you need

  • Board: Waveshare ESP32-S3-Touch-AMOLED-1.8 V2: ESP32-S3, 16 MB flash, 8 MB PSRAM, onboard microSD slot.
  • Connection: a USB data cable and a computer with access to the serial port.
  • For the download: uv supplies the uvx command below. ESP-IDF is not needed.
  • For source builds: ESP-IDF v5.5 with its environment activated, plus FFmpeg to convert movies.
  • Optionally: a FAT32 microSD card for clips longer than flash allows.

The display is 368 × 448 pixels. Flashing replaces the firmware currently on the board.

Run it

Download day-06-movie.bin and open a terminal in the download directory. This is a merged image containing the bootloader, partition table, application, and the media partition.

Find your serial port:

# macOS
ls /dev/cu.usbmodem*
# Linux
ls /dev/ttyACM*

On Windows, use the board’s COM port from Device Manager. Replace PORT with your port, then flash the image at 0x0:

uvx esptool --chip esp32s3 --port PORT \
  write-flash 0x0 day-06-movie.bin

When flashing completes, the bunny plays.

How it works

Two frame buffers in PSRAM split the work: one receives the next frame from storage, the other holds the pixels LVGL is allowed to draw. The read happens outside the display lock so the UI keeps drawing the previous frame; the swap happens inside it:

// LVGL must never draw a frame while we are overwriting its pixels.
bsp_display_lock(0);
lv_image_cache_drop(&frame);
memcpy(visible, staging, FRAME_BYTES);
lv_obj_invalidate(image);
bsp_display_unlock();

The raw format carries no metadata — width, height, and rate are a contract between the FFmpeg command and the firmware constants:

ffmpeg -i input.mp4 -t 3 -an \
  -vf "fps=10,scale=368:224:force_original_aspect_ratio=decrease,pad=368:224:(ow-iw)/2:(oh-ih)/2:black,setsar=1" \
  -c:v rawvideo -pix_fmt rgb565le -f rawvideo movie.rgb

The budgets are the lesson. One frame is 164,864 bytes; a second is 1.6 MB; the three-second clip is 4,945,920 bytes — far past the default 1 MiB app partition, so this day introduces a custom partition table: 3 MiB for the app and a 12 MiB raw media partition, no filesystem overhead. Twelve MiB holds 7.6 seconds at this format’s rate — which is exactly why the player also mounts the onboard microSD slot (bsp_sdcard_mount(), SDMMC 1-bit): convert without -t 3, drop movie.rgb on a FAT32 card, and the storage ceiling moves from megabytes to gigabytes. One minute of raw frames is ~94 MiB; the two RAM buffers never grow. If the card or file is missing, the firmware falls back to the flash clip — watch serial for source=SD or source=flash, because a successful fallback looks exactly like a successful SD test.

What went wrong

Resize before playback

The first attempt stored 184 × 224 frames and asked LVGL to scale them 2× on the board. Submissions fell to about 3.7 fps with repeated missed deadlines — the scaler ate the frame budget. Converting to full 368-pixel width on the computer doubled the file and removed the on-board work: the native-size build reported 10.01 fps with one initial late deadline, then 10.00 fps with zero late frames in the following windows. Resize where the CPU is cheap.

Check the result

  • The bunny clip plays at ten frames per second and loops at the boundary.
  • Serial reports the submission rate, late-frame count, and source=flash (or source=SD with a card).
  • With a longer movie.rgb on a FAT32 card: the card’s clip plays instead.
  • A missing card or file falls back to the flash clip and says so.

Recorded evidence · September 4, 2026: Native-size flash playback was observed over serial at 10.0 fps with stable heap and PSRAM across the reporting windows (submission rate, not certified panel frames). The SD path is unverified — the one mount attempt returned a timeout and the flash fallback played; card compatibility and sustained reads remain open, noted in the verification notes.

Used resources

Build and change it

Clone the repository once:

git clone https://github.com/chantastic/esptember.git
cd esptember

With the ESP-IDF environment activated, run from the repository root:

cd days/day-06-movie/firmware
idf.py build
idf.py -p PORT flash monitor

idf.py merge-bin produces the single image including the media partition — check that the media bytes land at 0x310000. To change the clip, replace firmware/main/movie.rgb and rebuild; the build checks the partition’s capacity and the app stops at the clip’s true length. The next step this player wants is MJPEG — compressed frames, a JPEG decoder, and roughly ten times the runtime per megabyte; the README’s conversion command for that future is in the source tree.

The implementation

Firmware source

Read the files here or open the project on GitHub ↗.

CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(esptember-day-06-movie)
main/CMakeLists.txt
idf_component_register(SRCS "main.c")
file(SIZE "${CMAKE_CURRENT_LIST_DIR}/movie.rgb" movie_bytes)
math(EXPR partial_frame "${movie_bytes} % (368 * 224 * 2)")
if(movie_bytes EQUAL 0 OR NOT partial_frame EQUAL 0)
    message(FATAL_ERROR "Movie must contain complete 368x224 RGB565 frames")
endif()
if(movie_bytes GREATER 12582912)
    message(FATAL_ERROR "Movie exceeds the 12 MiB media partition")
endif()
target_compile_definitions(${COMPONENT_LIB} PRIVATE MOVIE_FLASH_BYTES=${movie_bytes})
esptool_py_flash_to_partition(flash "media" "${CMAKE_CURRENT_LIST_DIR}/movie.rgb")
main/idf_component.yml
dependencies:
  waveshare/esp32_s3_touch_amoled_1_8: "==2.0.3"
  espressif/esp_io_expander_tca9554: "*"
  lvgl/lvgl: "==9.5.0"
main/main.c
// ESPtember Day 06 — A Movie
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c_master.h"
#include "bsp/esp-bsp.h"
#include "lvgl.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"

// --- AXP2101 power management ------------------------------------------
// The BSP doesn't configure the board's AXP2101 PMU at all. Its default
// VBUS input limit is 500 mA — once battery charging ramps up, the
// ESP32-S3 + AMOLED + charger exceed that and the PMU cuts system power
// (screen freezes/blanks, USB drops, ~1–4 min in). Raise the input
// limit and cap the charge current so the budget always fits.

#define AXP2101_ADDR 0x34

static i2c_master_dev_handle_t pmu;

static uint8_t pmu_read(uint8_t reg)
{
    uint8_t val = 0;
    i2c_master_transmit_receive(pmu, &reg, 1, &val, 1, 100);
    return val;
}

static void pmu_write(uint8_t reg, uint8_t val)
{
    uint8_t buf[2] = {reg, val};
    i2c_master_transmit(pmu, buf, 2, 100);
}

static void pmu_init(void)
{
    bsp_i2c_init();
    i2c_device_config_t cfg = {
        .device_address = AXP2101_ADDR,
        .scl_speed_hz = 100000,
    };
    ESP_ERROR_CHECK(
        i2c_master_bus_add_device(bsp_i2c_get_handle(), &cfg, &pmu));
    // 0x16 bits[2:0]: VBUS input current limit — 2 = 900 mA
    pmu_write(0x16, (pmu_read(0x16) & 0xF8) | 0x02);
    // 0x62 bits[4:0]: battery charge current — 9 = 300 mA
    pmu_write(0x62, (pmu_read(0x62) & 0xE0) | 0x09);
}
// --- V2 panel reset --------------------------------------------------
// On V2 boards (CO5300 panel, CST816-family touch) the panel's reset
// line sits behind a TCA9554 I/O expander that neither the BSP nor the
// examples ever drive. Left floating, the panel may come up — and then
// randomly drop dark minutes later while every esp_lcd call still
// returns ESP_OK. Pulse reset like the factory firmware does, then
// hold it high. Must run BEFORE bsp_display_start().
// https://github.com/waveshareteam/ESP32-S3-Touch-AMOLED-1.8/issues/12

#include "esp_io_expander_tca9554.h"

static void panel_reset_release(void)
{
    esp_io_expander_handle_t expander = NULL;
    ESP_ERROR_CHECK(esp_io_expander_new_i2c_tca9554(
        bsp_i2c_get_handle(), ESP_IO_EXPANDER_I2C_TCA9554_ADDRESS_000,
        &expander));
    const uint32_t reset_pins =
        IO_EXPANDER_PIN_NUM_0 | IO_EXPANDER_PIN_NUM_1 | IO_EXPANDER_PIN_NUM_2;
    ESP_ERROR_CHECK(
        esp_io_expander_set_dir(expander, reset_pins, IO_EXPANDER_OUTPUT));
    ESP_ERROR_CHECK(esp_io_expander_set_level(expander, reset_pins, 1));
    vTaskDelay(pdMS_TO_TICKS(100));
    ESP_ERROR_CHECK(esp_io_expander_set_level(expander, reset_pins, 0));
    vTaskDelay(pdMS_TO_TICKS(300));
    ESP_ERROR_CHECK(esp_io_expander_set_level(expander, reset_pins, 1));
}

static void board_start(void)
{
    pmu_init();
    panel_reset_release();
    if (!bsp_display_start()) abort();
    ESP_ERROR_CHECK(bsp_display_backlight_on());
}

static void heartbeat(const char *lesson)
{
    while (1) {
        ESP_LOGI("media", "%s alive: uptime=%llds heap=%u psram=%u", lesson,
                 esp_timer_get_time() / 1000000,
                 (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
                 (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
        vTaskDelay(pdMS_TO_TICKS(10000));
    }
}


#include <string.h>
#include "esp_partition.h"
#include "src/misc/cache/instance/lv_image_cache.h"

#define MOVIE_WIDTH 368
#define MOVIE_HEIGHT 224
#define MOVIE_FPS 10
#define FRAME_BYTES (MOVIE_WIDTH * MOVIE_HEIGHT * 2)

static lv_image_dsc_t frame;

void app_main(void)
{
    board_start();
    const esp_partition_t *media = esp_partition_find_first(0x40, 0, "media");
    if (!media || MOVIE_FLASH_BYTES > media->size || MOVIE_FLASH_BYTES % FRAME_BYTES) {
        ESP_LOGE("media", "Invalid flash movie size or partition");
        abort();
    }
    FILE *sd_file = NULL;
    size_t movie_bytes = MOVIE_FLASH_BYTES;
    esp_err_t sd_result = bsp_sdcard_mount();
    if (sd_result == ESP_OK) {
        sd_file = fopen(BSP_SD_MOUNT_POINT "/movie.rgb", "rb");
        if (sd_file) {
            if (fseek(sd_file, 0, SEEK_END) != 0) {
                fclose(sd_file);
                sd_file = NULL;
            } else {
                long size = ftell(sd_file);
                if (size <= 0 || size % FRAME_BYTES != 0 || fseek(sd_file, 0, SEEK_SET) != 0) {
                    ESP_LOGW("media", "SD movie must contain complete 368x224 RGB565 frames");
                    fclose(sd_file);
                    sd_file = NULL;
                } else {
                    movie_bytes = (size_t)size;
                }
            }
        }
        if (!sd_file) ESP_LOGW("media", "No valid /sdcard/movie.rgb; using flash demo");
    } else {
        ESP_LOGW("media", "SD mount: %s; using flash demo", esp_err_to_name(sd_result));
    }

    uint8_t *staging = heap_caps_malloc(FRAME_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    uint8_t *visible = heap_caps_calloc(1, FRAME_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    if (!staging || !visible) {
        ESP_LOGE("media", "Cannot allocate two %u-byte frame buffers", FRAME_BYTES);
        abort();
    }
    frame = (lv_image_dsc_t) {
        .header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_RGB565,
                    .w = MOVIE_WIDTH, .h = MOVIE_HEIGHT, .stride = MOVIE_WIDTH * 2 },
        .data_size = FRAME_BYTES, .data = visible,
    };
    bsp_display_lock(0);
    lv_obj_set_style_bg_color(lv_screen_active(), lv_color_black(), 0);
    lv_obj_t *image = lv_image_create(lv_screen_active());
    lv_image_set_src(image, &frame);
    lv_obj_center(image);
    bsp_display_unlock();

    const size_t frame_count = movie_bytes / FRAME_BYTES;
    ESP_LOGI("media", "READY day06: source=%s bytes=%u frames=%u target=%dfps",
             sd_file ? "SD" : "flash", (unsigned)movie_bytes, (unsigned)frame_count, MOVIE_FPS);
    TickType_t deadline = xTaskGetTickCount();
    size_t index = 0;
    unsigned frames = 0, late = 0;
    int64_t report_at = esp_timer_get_time();
    while (1) {
        // Read outside the display lock: the UI can keep drawing the previous frame.
        bool ok = sd_file ? fread(staging, 1, FRAME_BYTES, sd_file) == FRAME_BYTES
                          : esp_partition_read(media, index * FRAME_BYTES, staging, FRAME_BYTES) == ESP_OK;
        if (!ok) {
            ESP_LOGE("media", "Movie read failed at frame %u", (unsigned)index);
            break;
        }
        // LVGL must never draw a frame while we are overwriting its pixels.
        bsp_display_lock(0);
        lv_image_cache_drop(&frame);
        memcpy(visible, staging, FRAME_BYTES);
        lv_obj_invalidate(image);
        bsp_display_unlock();
        if (++index == frame_count) {
            index = 0;
            if (sd_file && fseek(sd_file, 0, SEEK_SET) != 0) {
                ESP_LOGE("media", "SD rewind failed");
                break;
            }
        }
        frames++;
        if (xTaskDelayUntil(&deadline, pdMS_TO_TICKS(1000 / MOVIE_FPS)) == pdFALSE) late++;
        int64_t now = esp_timer_get_time();
        if (now - report_at >= 10000000) {
            ESP_LOGI("media", "MOVIE source=%s submitted_fps=%.2f late=%u heap=%u psram=%u",
                     sd_file ? "SD" : "flash", frames * 1000000.0 / (now - report_at), late,
                     (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
                     (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
            frames = late = 0;
            report_at = now;
        }
    }
    if (sd_file) fclose(sd_file);
    free(staging);
    // The displayed image still owns a reference to visible; retain that buffer.
    bsp_display_lock(0);
    lv_obj_t *error = lv_label_create(lv_screen_active());
    lv_label_set_text(error, "Movie read failed");
    lv_obj_align(error, LV_ALIGN_BOTTOM_MID, 0, -24);
    bsp_display_unlock();
    heartbeat("day06 stopped");
}
partitions.csv
# Name, Type, SubType, Offset, Size, Flags
nvs,data,nvs,0x9000,0x6000,
phy_init,data,phy,0xf000,0x1000,
factory,app,factory,0x10000,0x300000,
media,0x40,0,0x310000,0xC00000,
sdkconfig.defaults
# ESPtember Day 06
CONFIG_IDF_TARGET="esp32s3"

# Board: Waveshare ESP32-S3-Touch-AMOLED-1.8 (16MB quad flash, 8MB octal PSRAM)
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y

# Console over the board's USB-C port (USB-Serial/JTAG peripheral)
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y

# Optimize for size
CONFIG_COMPILER_OPTIMIZATION_SIZE=y


# LVGL dark theme: white text, black background — zero styling code,
# and on an AMOLED, black pixels are simply off.
CONFIG_LV_THEME_DEFAULT_DARK=y

CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_LV_USE_CLIB_MALLOC=y
# CONFIG_LV_USE_BUILTIN_MALLOC is not set
# CONFIG_BSP_SD_FORMAT_ON_MOUNT_FAIL is not set