Disclosure: I work for Apollo Automation. The ESPHome Starter Kit in this post is one of our kits.
I wanted a short video of my doorbell doing something stupid. You press the button outside, and the ESPHome Starter Kit on my desk flashes pink and purple while its buzzer plays the Pink Panther theme.
I don’t like editing and I’m not good at it. So I didn’t do it. I had Claude cut the whole thing in ffmpeg while I watched the output and told it what looked wrong.
That last part turned out to be the entire job.
The build was the easy bit
The kit’s buzzer is an output, not an entity, so Home Assistant can’t touch it directly. You expose an API action in ESPHome Device Builder that calls rtttl.play with the tune baked in, and then Home Assistant can call it like anything else. The LEDs are already a light entity, so those need nothing.
Sound comes from the device, light comes from Home Assistant, one automation fires both:
- alias: Make noise and light at the same time
parallel:
- alias: Play the Pink Panther theme on the buzzer
action: esphome.esphome_starter_kit_play_doorbell
- alias: Flash the RGB LEDs for the length of the tune
sequence:
- repeat:
count: 11
sequence:
- action: light.turn_on
target: {entity_id: light.esphome_starter_kit_rgb_leds}
data: {rgb_color: [255, 20, 147], brightness_pct: 100, transition: 0}
- delay: {milliseconds: 300}
count: 11 isn’t arbitrary. The RTTTL string is 18.5 beats at 160bpm, which is 6.94 seconds, so eleven flashes at 300ms each stop just before the last note.
You need transition: 0. Device Builder doesn’t write a default_transition_length, so ESPHome falls back to a one second fade and every flash smears into the next one.
Then it stopped working mid-testing. Traces green, device online, API action returning fine, every layer reporting success. The ribbon cable had come out of the connector. The ESP32 was still happy on Wi-Fi with nothing attached to the other end, and Home Assistant has no way to see past that, so there was never going to be an error anywhere.
First thing it got wrong: it handed me clips, not a video
I asked for a video. It gave me six clips and a shot list and told me to assemble them in an editor.
Handing me parts meant every clip got encoded once on its own and again when they were joined, which is quality thrown away for nothing. When I pushed back it rebuilt the whole thing as a single ffmpeg render straight from the camera files. One encode, no intermediates.
It should have done that the first time. I had to ask.
Then I said “there are artifacts” four times
The picture looked wrong. Blocky, smeary, I couldn’t be more precise than that. Claude had a theory every time, and was wrong every time.
Round one was compression. It raised the bitrate, dropped CRF, turned on aq-mode=3 to push bits into flat dark areas. Marginally bigger file, same complaint.
Round two was generational loss. It was confident about this one and it made sense, since clips encoded twice would degrade. Then it measured, and every hop came back between 50 and 66 dB PSNR, which is basically transparent. Wasteful, but not what I was looking at.
Round three was mine. I said I thought the Python script it had written to do the zoom effect had stripped something out. It checked, and I was right. Piping frames out to Python and back had removed all four color tags from the file, and pushed the green and blue channels four to six times further from the source than doing the same job in ffmpeg. It fixed that. The picture still looked wrong.
So I said it again, and told it to spin up a second agent to audit its own work, because describing the symptom clearly wasn’t getting me anywhere.
The audit found what neither of us was looking at
The second agent measured everything end to end and came back with a list. Two things mattered.
It ruled out generational loss with numbers, which finally stopped Claude returning to it.
Then it found something nobody had checked. The audio was at -24.8 LUFS. YouTube normalizes to -14 and never boosts quiet uploads, so the video would have played about 11 dB softer than everything around it in the feed. I’d been staring at the picture for hours and not one of us had measured the sound.
The audit also got something wrong, which I liked. It flagged the final audio as 93 kbps rather than the 256k that was set, and called it a bug. It wasn’t. Two thirds of the video was silent at that point, AAC encodes silence for almost nothing, and 36 percent of 253k is 92k. The average was being dragged down, that’s all. Claude checked instead of taking the audit’s word for it.
The actual cause was frame timing
Speeding 30fps footage to 1.5x while still outputting 30fps means keeping two frames out of every three. At 1.3x you keep ten out of thirteen. Motion advances unevenly, and that reads as a stutter.
There was a 1.5x ramp on the cable assembly and a 1.3x ramp on the screen recording. Together that was 17 of 38 seconds, right through the middle. No amount of bitrate fixes it. The frames that would smooth the motion were thrown away before anything got encoded.
The fix was to stop speeding things up. The assembly plays at real speed now with one cut where the hands happen to line up. The screen recording plays at real speed too, cut into four pieces that land where a dialog opens or closes, so the screen was changing anyway. Everything at 1.0x, and the complaint went away.
If you know you want a speed ramp, shoot at 60fps.
The music went the same way
I said the background music was too loud. It set it to 30 percent. It came back the same.
I said it again. It set it to 30 percent again, measured, and the section was still sitting where it started.
loudnorm was doing it. Run single pass, it operates in dynamic mode, which is a leveler, and it found a quiet stretch of music-only audio and pushed it straight back up. It says so in its own output if you ask for the json:
"normalization_type" : "dynamic"
Every reduction was being reversed by the next filter in the chain. Swapping to a static volume= plus a limiter dropped that section by 6.2 dB, which is what I had asked for twice.
Then the peaks wouldn’t come down either. alimiter has a level option that defaults to true and auto-normalizes the output after limiting, undoing the headroom it just created. Set level=0 and it behaves.
Both defaults make sense for someone who wants a file normalized and handed back. They fight you the moment you want to set the balance yourself.
Smaller things that cost time
iPhone .MOV files are 3840x2160 landscape with a -90 rotation flag in the metadata. ffmpeg rotates on decode and then copies the flag to your output, so players rotate it a second time and your vertical video comes out sideways. Use -display_rotation 0 on the input and transpose it yourself.
One file was also ruined before anyone touched it. I had sent footage to myself through Discord’s phone app, which quietly re-encoded 4K down to 720p Baseline and stripped every Apple metadata tag. Google Drive doesn’t. If you’re not sure a file is an original, look for com.apple.quicktime.model. If it’s gone, something has been through it.
What I’d take from this
Claude is good at ffmpeg. It knows the filters, it writes the graphs, and it can measure things I wouldn’t know how to measure. Left alone it would have shipped me a video with the audio 11 dB too quiet and a stutter through the middle, and it would have sounded sure of itself the whole way.
What worked was refusing to accept an answer I could see was wrong, even when I couldn’t explain why it was wrong. “It still looks bad” turned out to be better information than four theories about why it shouldn’t.
The other thing that worked was making it check itself. One command spun up an agent that ruled out a theory it had spent an hour on, and found a defect none of us had thought to look for.
37.7 seconds, 2160x3840, 10-bit, one render straight from the camera files.
I still owe it a voiceover.