ESP32-CAM Timelapse Recorder — Interval Photos to SD Card
Why Build This Instead of Just Using a Phone
The honest answer is that I wanted to point a camera at a 3D print for eleven hours and not think about it again until it was done. A phone works for the first twenty minutes of any timelapse — then it locks, or a notification interrupts the camera app, or the battery gives out at hour six and you're left with half a timelapse and a lesson about propping your phone against something more reliable. An ESP32-CAM on a 5V wall supply doesn't care how long the shoot runs. Plug it in, walk away, come back when it's done.
The AI Thinker ESP32-CAM is a strange little board — genuinely capable camera hardware bolted onto a microcontroller that's also expected to write files, manage a WiFi radio it isn't even using for this project, and do it all without the benefit of an operating system holding its hand. Getting a clean, well-exposed frame onto an SD card at a controlled interval, indefinitely, without a laptop attached, touches almost every constraint the board has: PSRAM, SD_MMC bus sharing, deep sleep timing, and the surprisingly fiddly business of getting the sensor to actually expose properly before you grab the frame you're going to keep. This build walks through all of it, plus the Python script that turns a folder of ten thousand tiny JPEGs into an actual video file at the end.
Why PSRAM Matters for Timelapse
The AI Thinker ESP32-CAM has 4 MB of PSRAM (Pseudo-Static RAM) connected via the SPI bus, separate from the small amount of internal SRAM built into the ESP32 chip itself. Without PSRAM enabled, the camera driver has nowhere to put a large frame buffer, so you're capped at FRAMESIZE_CIF — 352×288 — which is fine for a motion-detection preview and genuinely bad for anything you intend to watch as a finished video. With PSRAM enabled, the same board captures UXGA (1600×1200) frames without complaint, which is enough resolution for a timelapse that actually looks intentional rather than like a security camera clip from 2009.
This is also the single most common reason someone's ESP32-CAM sketch compiles fine, uploads fine, and then either crashes on the first capture or silently falls back to a resolution nobody asked for: PSRAM has to be explicitly enabled in the Arduino IDE board settings, and it's not the default. If psramFound() returns false when you'd swear the board has PSRAM, that setting — not the hardware — is almost always the actual problem.
The other constraint worth understanding up front: the microSD card and the camera share physical bus lines on this board (it's called SD_MMC mode, a 1-bit variant of the SD protocol repurposed to share pins with the camera interface). That means capture and file-write can't genuinely happen in parallel — the sequence is always capture frame into the buffer, then write that buffer to the card, then release the buffer, one step after another. It's not a bottleneck for a timelapse shooting a frame every 30 seconds or slower, but it's worth knowing about before assuming you can push this board to fast burst-capture rates without redesigning the whole capture loop around it.
Parts List
| Component | Notes |
|---|---|
| ESP32-CAM (AI Thinker) | The most common variant; other ESP32-CAM boards use different pin mappings |
| microSD card, 32 GB, Class 10 or U1 | Must be formatted FAT32 — the SD_MMC library doesn't support exFAT |
| FTDI USB-to-serial adapter (3.3V) | The ESP32-CAM has no onboard USB — this is how you flash it |
| 5V 2A power supply | The camera module briefly draws a real current spike on capture; USB-from-a-laptop power is often not quite enough for reliable long runs |
| Mini tripod or camera mount | Whatever keeps the lens pointed at the same spot for the whole shoot |
Wiring for Programming
The ESP32-CAM doesn't have a USB port, which catches people off guard the first time they unbox one. Flashing it means wiring an FTDI adapter to it temporarily:
| ESP32-CAM Pin | FTDI Pin |
|---|---|
| 5V | VCC (5V, not 3.3V — the board regulates internally) |
| GND | GND |
| U0R (RX) | TX |
| U0T (TX) | RX |
| GPIO 0 | GND (only during upload — see below) |
GPIO 0 to GND is the part everyone forgets. The ESP32-CAM only enters programming mode if GPIO 0 is grounded at the moment it powers on or resets. Wire a jumper from GPIO 0 to GND, power-cycle the board (or press its reset button if it has one — many breakout boards do), start the upload, and once you see "Connecting..." in the Arduino IDE output, the board is listening. After the upload finishes, remove the GPIO 0 jumper and power-cycle again — with it still grounded, the board boots back into programming mode instead of running the sketch, which looks identical to "the code isn't running" if you don't know to check for it.
The Code
Two design choices in here are worth calling out because they're easy to get wrong on a first attempt. First, this uses deep sleep between captures instead of a delay() loop, and that's deliberate — a delay(30000) sitting in loop() keeps the camera sensor, the WiFi radio, and the full CPU powered the entire time, which is wasteful for a board that's going to sit plugged into a wall for eleven hours anyway, and it also means any crash or memory leak in a long-running loop compounds over the whole shoot. Deep sleep resets the board completely between frames — setup() runs fresh every single time, camera and SD card get reinitialized from a clean state, and frameCounter survives across the sleep because it's declared with RTC_DATA_ATTR, which places it in the small slice of RAM that deep sleep doesn't wipe. The tradeoff is that a lot of the code you might expect to write once in setup() and never touch again — camera init, SD mount — runs on every single frame instead. For a 30-second interval that overhead is a non-issue; it would matter more if you tried to push this toward one-second intervals.
Second, the first frame after camera init gets thrown away. Camera sensors need a handful of frames to settle their auto-exposure and auto-white-balance algorithms after waking from a cold start, and the very first frame out of a freshly initialized sensor is reliably darker or color-shifted compared to everything after it. Discarding that first frame and grabbing a second one after a short delay is a small amount of extra code that fixes a problem you'd otherwise only notice by scrubbing through the finished timelapse and seeing every single frame flicker slightly at a regular interval — which, once you know to look for it, is an obvious and slightly embarrassing artifact to leave in a finished video.
Choosing an Interval
The interval and the eventual playback frame rate together decide how much real time one second of finished video represents. At 30-second intervals played back at 24 FPS, each second of video covers 12 minutes of real time — an eleven-hour print finishes as a roughly 55-second clip, which is close to the sweet spot for something people will actually watch instead of losing patience with by the 20-second mark.
| Subject | Suggested Interval | Why |
|---|---|---|
| 3D print (multi-hour) | 20–40 sec | Enough motion between frames to read as continuous without the file count becoming unmanageable |
| Cloud/sky timelapse | 5–15 sec | Clouds move fast enough that longer intervals produce visible judder |
| Plant growth over days | 5–15 min | Growth is slow enough that shorter intervals just waste storage |
| Construction/long-term project | 15–60 min | Days-to-weeks timescale; storage budget usually the limiting factor, not motion smoothness |
A 32 GB card at UXGA resolution and quality 10 holds roughly 6,000–8,000 frames depending on scene complexity, which at a 30-second interval is around 50–65 hours of continuous shooting before the card fills. Longer shoots either need a bigger card, a coarser interval, or a scheduled offload — this sketch doesn't delete old frames automatically, on purpose, since silently losing footage partway through a shoot is a worse failure mode than the card just filling up and the board stopping cleanly.
Assembling the Final Video
Once the shoot is done, pull the SD card, copy the /timelapse folder to a computer, and run this against it:
The -pattern_type glob flag is what lets FFmpeg consume the %05lu.jpg filenames the sketch writes without needing them renumbered first — FFmpeg's default image-sequence input wants a strict %05d pattern starting from a specific number, and glob mode sidesteps that by just sorting whatever matches the wildcard alphabetically, which works cleanly since zero-padded five-digit filenames sort correctly as strings. -crf 18 sits close to visually lossless for x264 — going lower gives diminishing returns in quality for a large jump in file size, and going much higher starts introducing visible blocking in high-contrast footage like a 3D printer's illuminated build plate.
Getting the Shot Actually in Focus
The camera module on most AI Thinker boards is a fixed-focus OV2640 with a manually adjustable lens ring — a small threaded barrel you twist to nudge focus, not an autofocus system. Nobody mentions this until after the first ruined shoot, which was exactly my experience: I set up an eleven-hour print timelapse, checked that the Serial Monitor was happily printing "Saved: /timelapse/00001.jpg" every 30 seconds, and walked away satisfied. Every single one of those frames turned out soft enough to be unusable, because the lens had shipped focused for roughly a meter out and I'd mounted the board about fifteen centimeters from the print bed.
The fix is unglamorous: before starting a real shoot, run a short test capture, pull a frame off the card, and actually look at it at full resolution — not the tiny thumbnail your file browser shows by default. If it's soft, gently rotate the lens ring in small increments, capture again, and repeat until a test frame at the actual working distance comes back sharp. It takes five minutes and it's the difference between a timelapse worth watching and eleven hours of footage you have to throw away. Once it's focused correctly for a given mounting distance, it holds — this isn't something that drifts on its own, so it only needs doing once per physical setup, not before every shoot.
Framing matters more than it seems like it should for something as forgiving as a timelapse. A slightly wider shot than feels necessary gives some headroom if the mount shifts a millimeter or two over a multi-hour run — vibration from a nearby 3D printer's stepper motors is enough to very slowly walk a lightweight mount out of position over several hours, and a tighter frame makes that drift obvious and distracting in the finished video where a wider frame would have absorbed it without anyone noticing.
A Note on Power
The datasheet current draw for this board during capture is higher than people expect from something this small — the camera sensor, the flash LED if it's enabled, and a brief burst of SD write activity can spike well past what the module draws at idle. It's rarely enough to cause a problem on a proper 5V 2A supply, but it's exactly the kind of load that exposes a marginal USB cable or a cheap phone charger that can't hold its rated voltage under a transient spike. If a shoot mysteriously stops a few hours in with no error printed to Serial — because nothing was connected to Serial to catch it — a brownout reset from an underpowered supply is the first thing worth ruling out, ahead of anything in the code. Swapping to a supply and cable you know are rated for a full 2A, not just labeled that way, resolves more of these "randomly stopped overnight" reports than any firmware change does.
Setup Steps
- Format the SD card as FAT32 — exFAT is not supported by the SD_MMC library and will fail silently or refuse to mount.
- In Arduino IDE, go to Tools → PSRAM and select "OPI PSRAM." Skipping this step is the most common reason UXGA capture fails on hardware that objectively has the PSRAM chip present.
- Wire the FTDI adapter with GPIO 0 shorted to GND, then upload.
- Remove the GPIO 0 jumper and power-cycle the board. The onboard flash LED should pulse on each capture.
- Let it run for the full shoot, then pull the SD card and copy the
/timelapsefolder to a computer. - Install FFmpeg and run
python3 assemble_timelapse.py --input ./timelapse --fps 24. - Adjust
INTERVAL_SECandJPEG_QUALITYin the sketch to trade storage against shoot duration and image quality for the next run.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| "Camera init failed" on every boot | PSRAM not enabled in board settings | Tools → PSRAM → OPI PSRAM, then re-upload |
| "SD card mount failed" | Card formatted exFAT, or a bad/loose card | Reformat as FAT32; reseat the card |
| Upload fails / board not detected | GPIO 0 not grounded, or FTDI TX/RX swapped | Recheck the programming jumper and wiring; try swapping TX/RX if nothing else works |
| Every frame looks slightly dark or color-shifted | The "discard first frame" step got removed or skipped | Confirm captureAndSave() still grabs and discards a warm-up frame before the real capture |
| Frame count lower than expected for the shoot duration | Board rebooted mid-shoot (power blip) or SD write failures | Check Serial output during a shorter test run for "SD write failed" messages |
| Final video judders or skips | Some intervals longer than expected due to slow SD writes | Try a faster (higher class) SD card, or increase INTERVAL_SEC slightly |
Common Mistakes
Not enabling PSRAM before the first upload. This is responsible for most of the "camera works but the resolution is stuck at something tiny" reports, and it's a board-settings toggle, not a code fix.
Powering the board from a laptop's USB port for a long unattended shoot. It technically works for short tests, but a laptop USB port going to sleep, or the laptop itself closing, ends the shoot. A dedicated 5V wall supply is what actually survives an unattended overnight run.
Assuming the SD card and camera can operate independently. They share a bus. Trying to restructure this code to write frames asynchronously while capturing the next one will run into the same physical pin-sharing constraint this sketch is already built around — the sequential capture-then-write pattern here isn't a missed optimization, it's the actual hardware limit.
Skipping the discarded warm-up frame to "save time." It costs roughly 200 milliseconds per capture cycle, which is irrelevant at a 30-second interval, and removing it reintroduces the exposure-flicker artifact across the entire finished video.
Extensions to Try
- Timestamp overlay — burn the capture time into each frame before saving, using a small bitmap font, so the finished timelapse shows elapsed time
- Motion-triggered capture — skip a scheduled interval entirely and instead capture on PIR sensor trigger, useful for wildlife or driveway timelapses
- WiFi upload instead of SD — stream frames to a home server over WiFi in projects where physically retrieving the SD card isn't convenient
- Battery-and-solar operation — pair this with the same deep-sleep pattern covered in SolderHub's ESP32 Deep Sleep Guide for a fully untethered outdoor timelapse rig
