Slideshow Studio — turn a folder of images into a Ken Burns slideshow
Yesterday an old coworker called asking about the slideshow-from-images code I'd written. That came out of a 2025 project for a news-video company — stitching photos (wire shots, stock, in-house) into b-roll for newsroom segments.
This time he wanted to turn a few fashion-shoot rolls into a quick slideshow to post — the kind of thing Lightroom, Apple Photos, Instagram, and TikTok all do automatically, and far more elaborately. Nothing fancy. The wrinkle was that my old code came wrapped in project context (internal configs, hooks into the newsroom pipeline), so handing the whole thing over would have left him peeling it all back out.
So I opened the laptop on a weekend afternoon and spent about two hours on it: pulled the Ken Burns core out, wrapped it in a minimal UI — no auth, no database, no cloud (just FastAPI on localhost calling ffmpeg), a single HTML page. The goal was the fastest possible flow: import images → click render → download the video. Anyone who only wants those three steps can use it without reading any docs.
The knobs are there for anyone who wants them: aspect ratio, per-slide duration, speed (slow/medium/fast), drag-reorder. Whoever wants control of the details has somewhere to go; whoever doesn't gets one click. It's a small tool — plenty of room to grow later with more presets and effects — but the direction stays the same: super-light, runs straight on a personal computer, not a web/mobile app.
An afternoon, a nice session — paired with Opus 4.8, occasionally missing Fable 5. MIT on GitHub. A few technical notes from the build follow.

The hardest problem: the image shakes
Anyone who has used ffmpeg's zoompan filter knows the feeling: you slow-pan an image over a few seconds, and instead of buttery motion you get pixel-by-pixel hops like an old film loop. The reason is that zoompan rounds the crop offset to integer pixels per frame — with a slow camera (zoom 1.06 over 4 seconds ≈ a delta of 0.0005 per frame in normalised zoom units, which works out to ~0.96 px on a 1920-wide source), the per-frame shift lands on 0 or 1 pixel with no in-between, and the result reads as a stutter.
My fix is dumb but effective: pre-scale the source so its short edge is ≥ 6000 px before feeding it to zoompan. At that resolution the per-frame delta lives well below one displayed pixel, ffmpeg interpolates smoothly, and the 1080p output is clean. One line of code, a few extra seconds per render, and nobody asks 'why does the video judder' anymore.
Crop or letterbox
Every image can have a different aspect ratio from the output. Two schools of thought: cover-crop or contain-with-bars. I expose both as a toggle. In Crop mode the image fills the frame and may lose subject — clean and simple when ratios match. In Letterbox mode the image keeps its ratio and the remaining bands are filled with a blurred copy of itself. The subtle bit is that the Ken Burns direction pool has to change too: if you letterbox a vertical image and then pan horizontally, the viewer's eye runs straight across the blurred bands. So I bias the pool toward the image's long axis — the motion stays on the photo instead of crossing the blurred fill.

Stack: just enough
- Backend: FastAPI + Uvicorn, about 265 lines of REST + static serving.
- Renderer: ffmpeg + ffprobe over subprocess. ~265-line pipeline.
- Frontend: a single HTML file, Tailwind from a CDN, SortableJS from a CDN for drag-reorder. No bundler, no node_modules.
I went out of my way to avoid frameworks. This is an internal tool, one user, runs on localhost. Every layer of abstraction is something I'd have to debug when my battery is dying and I'm chasing an 11 PM deliverable. Vanilla JS plus a Tailwind CDN gets me a respectable UI in 30 minutes, and 6 months from now I can still read it.
Small UX touches
A few things I let myself polish, because I'm also the user:
- Drag-to-reorder uses a FLIP animation — thumbnails slide into place instead of snapping. SortableJS does the heavy lifting; I just added a few transitions.
- Replace/delete buttons appear only on hover; clicking a thumbnail opens a full-size preview. Nothing fights for space when it isn't needed.
- The output gallery shows a thumbnail per rendered video, double-click the filename to rename inline, hover reveals × to delete and ↓ to download. The preview resizes to each video's native aspect when you switch.
- Per-slide duration is a 1–10 second slider; speed is a Slow/Medium/Fast dropdown; output ratio is 1:1, 16:9, 9:16, 4:3, 3:4. Every setting previews live in the main viewport.

Render pipeline
Each slide runs one of two paths, then build_slideshow concatenates the clips with -c copy so there is no second re-encode:
crop: [crop ratio] → scale ≥6000px → zoompan → encodeletterbox: blur-composite → scale 3x → zoompan → encode
The Ken Burns direction is randomized per slide from a pool of {pan horizontal, pan vertical, zoom in, zoom out}. The pool is filtered by the current image — portrait biases vertical, landscape biases horizontal, square gets all four.
What's next
The code is MIT on GitHub. I might add a 'random' duration preset so each slide gets a different length, and an option to drop in a music track with crossfades at the head and tail. But the core is solid: drop images, click render, get the video back a few seconds later. Some days that's all you need.
Update — 2026-06-20
A few days after publishing this post I went back and touched the app a few more times. What changed:
- Fit mode (Crop / Letterbox) is now per-slide. The toggle used to live in the sidebar and apply to the whole output — now each slide carries its own, and the toggle only appears when that slide is the one in the preview. A mixed portrait + landscape roll no longer forces one compromised fit choice.
- You can drag inside the preview to reposition the crop window. The cursor turns into a 4-way arrow; the drag is locked to whichever axis actually overflows (no jiggle on a fitted side) and clamped at the edges so panning never reveals empty bars. Each slide remembers offset_x / offset_y in [0, 1] and the backend frames the rendered video exactly like the preview.
- Generate is non-blocking now. Clicking Generate drops a 'Generating…' placeholder thumbnail at the right of the gallery and parks you on it. Other video thumbs stay clickable mid-render; clicking back on the placeholder shows the loading overlay again. When the render completes the placeholder swaps to the real thumb, and it only auto-plays if you were still on the placeholder.
- Bulk select + delete from the sidebar. Each card grows a checkbox on hover (or persistently while any selection is active); a selection bar under the Assets header offers Select-all (with an indeterminate state) and Delete. A new /api/images/bulk-delete endpoint takes a list of filenames, skips missing entries, and rejects path traversal.
- Output is 60 fps now (was 30). Slightly heavier to encode, but combined with the 6000-px pre-scale the motion is effectively step-free: the pre-scale defeats the integer-rounding stair-step, and doubling the frame rate halves each per-frame shift on top of that.
- While I was in there I tried cubic ease-in-out (smoothstep 3p²-2p³) on the zoompan filter for a more 'filmic' soft start/stop, then reverted. Smoothstep's velocity is zero at both ends, so the per-frame delta near the start and end is tiny — combined with zoompan's integer-pixel rounding, you get clusters of identical frames followed by a 1-px jump (visible stair-step at every slide boundary). Linear keeps the delta constant across the clip, predictable, no dead-frame clusters — still the right default for slow Ken Burns.
Small additions, but they smoothed out the rough edges I kept hitting in actual use. The core stays the same: import, render, download — three steps, no docs needed.
Share