430 videos a month on one laptop: an on-device content pipeline, and everything that broke
This isn't a "look what AI can do" post. The pipeline works, but almost everything interesting about it is what went wrong on the way there. Most of those failures were silent — the job reported success and produced nothing, or produced something nobody ever saw. Those are the expensive ones, and they're the reason this post exists.
The stack
Everything below runs locally unless noted:
| Stage | Tool |
|---|---|
| Image generation | Z-Image-Turbo (4-bit) via mflux on MLX |
| Narration | Kokoro-82M TTS |
| Motion | Depth-Anything-V2 → depth-based parallax |
| Encode | ffmpeg with VideoToolbox (hardware H.264) |
| Scheduling | launchd (16 agents) |
| Publishing | Facebook / Instagram Graph API, TikTok API |
Output is 1080×1920, H.264 yuv420p, 30 fps, AAC 48 kHz stereo — which satisfies Reels, TikTok and Facebook without re-encoding per platform.
The reason it's on-device isn't ideology. At this cadence, per-image API costs compound: 14 videos a day at ~6 images each is roughly 2,500 images a month. At $0.04 an image that's $100/mo for stills alone, before narration or video. On-device, the marginal cost is electricity.
What broke, in rough order of how long it took to notice
1. Two jobs, one GPU, not enough RAM
mflux peaks around 9.1 GB of MLX memory generating a single 768×1344 image. On an 18 GB machine that's fine — once. My scheduler happily started a second brand's render while the first was still going, and 18.2 GB of demand on 18 GB of RAM doesn't fail cleanly. It swaps. And because the data volume was sitting at 96% full, there was nowhere to swap to, so the whole machine would wedge — twice ending in a watchdog kernel panic.
Two fixes. First, taskpolicy -b on every render job, which confines it to efficiency cores so
it can't starve the UI. Second — and more useful — I stopped assuming local was automatically
the right choice. More on that below.
The general lesson: on a laptop, peak memory of a single inference is a scheduling constraint, not just a capacity question. Nothing in my job definitions expressed "these two must not overlap," so nothing prevented it.
2. launchd doesn't give you your shell's PATH
Scheduled jobs ran, exited 0, and produced no video. The scripts called ffmpeg and gsutil,
which live in /opt/homebrew/bin — not on launchd's default PATH. The subprocess failed, the
wrapper didn't check, and the job reported success.
Worse, the notification step did run, so I got a text saying the content was ready. A cheerful "✓ done" for a file that didn't exist. Any scheduled job on macOS needs its PATH set explicitly in the plist, and every subprocess needs its return code checked.
3. Watchdogs rot when you rename things
I have a health check that verifies each scheduled job produced fresh output today. It globs for expected filenames. Then I added new generators with slightly different naming, and the globs silently stopped matching — the watchdog reported healthy because it was looking for files that no longer existed under those names.
A monitor that can't fail is worse than no monitor, because it manufactures confidence. Now the globs are date-anchored patterns rather than name-anchored, and adding a generator means checking the watchdog in the same commit.
4. Detection without a trigger
This one I only found this week, and it's my favourite because the comment above the bug explained exactly why the bug mattered.
The health check gathers per-platform publish failures into a list, appends them to the alert body, and then decides whether to send the alert:
undelivered = _publish_failures_today() # populated correctly
...
should_text = report or bool(missing) or bool(failed) # ← undelivered isn't here
Delivery failures were detected, formatted, and attached to a message that never got sent unless something else independently triggered it. A comment directly above that code noted the check existed because posts had been lost silently for days. The detection landed; the trigger didn't.
5. Partial failures look exactly like noise
Related, and subtler. One platform caps how many unpublished drafts can sit in your queue. Once I hit the cap, the first few posts each day succeeded and the rest were rejected. Every day for a week.
My alerting listed failures per-post, so each day showed "a couple of failures" — indistinguishable from an ordinary blip. Nineteen posts vanished over seven days before anyone noticed the shape.
The naive fix — "alert if a platform has zero successes for N days" — never fires against this, because there are always a few successes. What works is grouping failures by cause, counting distinct days each cause appears, and suppressing any cause the platform has since recovered from (last failure older than last success). Without that last clause it pages you about problems you already fixed, and an alert that cries wolf gets ignored — which is the exact failure the check was written to prevent.
The economics I got wrong
I assumed on-device was the cheap option and cloud was the expensive one. Measuring it properly produced a more interesting table. Same prompt, portrait aspect, this week:
| Backend | Resolution | Time / image | Cost |
|---|---|---|---|
| mflux Z-Image-Turbo, local | 768×1344 | 2 min 05 s | $0 |
| HF Space on ZeroGPU | 720×1280 | ~12 s | included with PRO |
| FLUX.1-schnell via HF Inference Providers | 1152×2048 | ~3 s | ~$0.01 |
| Imagen 4 @ 2K | 1536×2816 | ~14 s | ~$0.04 |
The local path was ten times slower than a free hosted Space and carried the 9.1 GB memory cost that was wedging the machine. I'd been paying a large hidden price for "free."
I'd also left the ZeroGPU minutes included with my account completely unused — my generation chain tried local first, local always succeeded, so the hosted path existed as a fallback that never once ran. Reordering it was a two-line change that made daily generation 10× faster and removed an entire class of memory contention.
Worth saying plainly: check what your plan already includes before optimizing. I'd been treating a paid, capped credit pool as my only cloud option while an included compute quota sat at zero usage.
The bigger structural mistake
For a long time, every video generated its images at render time — about 12 minutes of GPU work in front of each render.
Almost none of that needed to be per-video. The images are drawn from a bounded set of themes; what changes daily is the script, not the visual vocabulary. So now there's a library of pre-rendered frames on disk, and rendering just picks files: least-recently-used within the day's theme, falling back to a general pool.
Generation time per video went from ~12 minutes to zero. The library takes about seven minutes to build and lasts months before anything repeats.
The rule I'd extract: if you're generating at request time, check whether the input space is actually unbounded. Mine wasn't. I'd been recomputing a small, stable set on every single run because the naive architecture — generate what you need, when you need it — never got questioned.
One nice side effect: curation became rm. The picker globs whatever files survive in the folder,
so deleting a bad frame in Finder is the cull. No manifest to keep in sync.
What I'd tell myself starting over
- Treat silent success as the primary failure mode. Every one of the expensive bugs above reported success. Check return codes, verify artifacts exist, and make the watchdog prove the output is fresh rather than merely present.
- Alert on the shape, not the event. Per-item failure lines hide systemic problems. Group by cause, track it across days, and suppress what's recovered.
- Measure the "free" path. Local inference has real costs — wall-clock, memory pressure, disk, and thermal — they're just not on an invoice.
- Question per-request generation. Ask whether the input space is bounded before building a pipeline that recomputes it forever.
None of this needs a GPU cluster. It needs a laptop, a scheduler, and a healthy suspicion of jobs that tell you they succeeded.
I build ChatterFix, a technician-first CMMS, and a handful of other products under TheGringo AI. The pipeline described here runs the content for all of them.