Engineering / Case study
How I built a local-first podcast clipping pipeline
No cloud APIs, no footage uploads, 6× smaller delivery. Here's the stack and what broke.
The problem
A podcast episode can leave a production team with hours of footage and a much shorter deadline for social clips. Someone still has to find a passage that makes sense outside the episode, cut it cleanly, fit it into a vertical frame, and make the captions readable. A transcript helps with navigation. It doesn't decide whether the first sentence gives a stranger a reason to keep watching.
The hosted tools I was trying to replace put the entire source file on someone else's servers. A $15–30 monthly subscription was another recurring cost, but the upload requirement was the larger design constraint. A team handling unreleased episodes needs to account for where that material goes. Upload time also comes before any useful work can begin, and automated highlight selection still leaves the editorial decision to someone on the team.
I built the processing around local files instead. Transcription, highlight selection, and rendering run on my machine. After the models and runtime are installed, those stages can run offline without a per-request API bill. The tradeoff is explicit: I own the GPU budget, dependency failures, and final review. Publishing an approved sample is a separate step from processing the source footage.
The stack
| Stage | Tool | Reason |
|---|---|---|
| Transcription | faster-whisper, local and CUDA-accelerated, with an approximately 1.5 GB model download. | Audio stays local. GPU inference avoids a remote transcription queue and usage-based API charges. |
| Highlight detection | Ollama with granite4.1:8b, or qwen2.5:3b for smaller GPUs. |
A local LLM selects candidate passages from the transcript. Model size is a memory-budget choice, and selection can run offline. |
| Reframing + captions | ffmpeg + libass | Explicit crop, encoding, audio, and subtitle settings. The render doesn't depend on an upload or a browser editor. |
| Manual review | I watch every clip's first 3 seconds before it goes out. | The model proposes a passage. I decide whether the opening works as a clip and whether the cut needs revision. |
The handoff between stages matters more than the model name. Transcription produces text and timestamps. Highlight selection works from that text; it does not need the original video uploaded to a multimodal service. ffmpeg then uses the selected interval to produce the vertical output and burn in the captions.
Local execution removes network transfer from the processing path, but it doesn't make latency disappear. Model loading, GPU contention, and encoding still take time. The approximately 1.5 GB model file is a disk footprint, not a promise about runtime VRAM. That distinction became important on an 8 GB card.
What broke
These were the failures that changed the setup and the delivery. The fixes below describe this Windows environment and this sample, rather than a benchmark across machines.
CUDA 12 DLL resolution on Windows
- Symptom
- Transcription stopped with
cublas64_12.dll is not foundwhen the GPU path tried to load. - Root cause
- CTranslate2 couldn't resolve the required CUDA 12 cuBLAS library in the Python environment. Having CUDA-related packages installed did not put every DLL where this loader expected it.
- Fix
- I copied the required DLLs from the
nvidia-cublas-cu12wheel into CTranslate2's directory under the virtual environment'ssite-packages. That resolved the lookup in this installation. It is a local workaround that needs checking again after dependency updates.
The 8 GB VRAM ceiling
- Symptom
granite4.1:8bat a 16K context, with Whisper and the ffmpeg stage in the workload, hit an out-of-memory failure on an 8 GB GPU.- Root cause
- The model weights were only part of the allocation. Context state and transcription buffers needed room too. The combined workload exceeded the available memory; an 8B model fitting on its own did not mean the pipeline would fit.
- Fix
- I used
qwen2.5:3bfor highlight selection to leave more headroom. Reducing the transcription batch size is another way to lower peak allocation. I would size the workload around the shared memory budget before increasing model size or context.
Cancelling a request didn't free the GPU
- Symptom
- After cancelling an Ollama request, VRAM stayed occupied and the next stage still lacked headroom.
- Root cause
- Request cancellation did not terminate the model runner in this session. The worker remained alive with its allocation. Stopping the caller was not sufficient to unload the model.
- Fix
- I had to manually terminate the lingering runner, identified as
llama-server.exein this setup, then confirm that VRAM had been released before restarting work. That process name is environment-specific; I would check the worker before terminating it.
A 15 MB sample was slow on 4G
- Symptom
- The clip on GitHub Pages took too long to load over a 4G connection. The MP4 was approximately 15 MB.
- Root cause
- I had carried a larger render into the web delivery step. Static hosting served the file as uploaded; there was no service generating a smaller playback version.
- Fix
- Re-encoding with
-crf 28 -vf scale=720:-2brought this clip down to approximately 2.5 MB, a 6× reduction. The scale keeps the aspect ratio and calculates an even height for the encoder. This is the result for that sample; duration, motion, and audio bitrate affect other files.
ffmpeg -i clip.mp4 -c:v libx264 -crf 28 -vf scale=720:-2 -c:a aac -movflags +faststart clip-web.mp4
This pass assumes the crop, captions, and loudness processing are already in clip.mp4. +faststart moves MP4 metadata to the front so playback can start before the whole file downloads.
The delivery
The output is a 9:16 vertical MP4 with H.264 video and burned captions rendered through libass. The captions travel with the picture, so playback doesn't depend on a separate subtitle track or player setting. Audio targets -14 LUFS integrated loudness through normalization, giving the export a defined loudness target. That target is separate from the video compression settings. I watch each clip's first 3 seconds before delivery, because a technically valid encode can still start mid-thought.
The compressed web sample is approximately 2.5 MB at 720 pixels wide, or 720 × 1280 for a 9:16 frame. The portfolio deployment on GitHub Pages uses static HTML and CSS plus one MP4 sample. It has no backend or database. The browser fetches the approved export directly, while the source footage, transcript, and inference stages stay on the local machine. The 6× reduction is a file-size comparison between the 15 MB and 2.5 MB versions of this sample, not a guaranteed reduction for every clip.
What I'd do differently
- Start with
qwen2.5:3b, rather than an 8B model, on any GPU under 12 GB. Establish a working memory budget with transcription in the workload before trying a larger model. - Pin CUDA wheel versions in a virtual environment. Don't rely on system CUDA to resolve Python runtime dependencies, and record any DLL workaround alongside the pinned environment.
- Compress the first clip for its actual delivery channel. Check the smaller export on a phone before repeating the upload cycle three times.