I opened my scheduling dashboard to check on a queue I'd built the week before, and found thirty-six product reels lined up over the next twelve days. Two platforms, eighteen videos each, going out twice a day.
Every single one of them was silent.
Not broken. Not missing. Just mute — a vertical card animating a product photo, a price, a rating, and no human sound anywhere in it. The kind of thing that looks completely finished until you play it with the volume up.
Why I cared, without pretending I have data
I'm not going to tell you silent video kills your reach, because I can't prove that from my own accounts. They're too new and I don't have a clean before-and-after to point at.
What I can tell you is the decision that made it a problem: I'd already ruled out music. The easy fix for a mute reel is a trending audio track, and on a product account that's what everyone does. I didn't want it. Music on a product review reads as an ad in a way a voice doesn't, and I'd rather have a plain narrator saying a true sentence about a keyboard.
Which left exactly one option. If no music, then a voice. And there were thirty-six of them.
The important part: they weren't videos, they were rows
Here's the thing that made a one-night fix possible, and it's the part worth stealing even if you never touch a product reel.
Those reels were never edited in a timeline. They came out of a generator with three pieces:
products.json— one entry per product: name, price, rating, review count, image, and a four-sentence script written for narration.- An HTML template — the vertical card itself. A slow camera push on the product photo, three stat panels, a fixed disclosure line, brand colours.
- A build script — the thing that turns row plus template into a finished MP4.
So "add a voice to thirty-six posts" was never thirty-six editing jobs. It was one change to the build script and a re-run.
The deliverable was never the videos. It was the generator that makes them. That's the difference between a busy night and an impossible one.
The build script now does this, per product: send the script to a text-to-speech API, get back the audio plus a file of sentence timings, use those timings as the scene boundaries in the template, validate, render. Scene two starts when sentence two starts, because it's the same number.
I wrote up the reasoning for the specific TTS choice in a separate head-to-head — short version: I used Amazon Polly here because it returns those timings, and the timings are what let a machine cut video without me watching it.
What "one night" actually contained
Thirty clips rendered before I stopped:
| Batch | Count | Notes |
|---|---|---|
| Arabic product reels | 16 | 14 generated cards, 2 built from footage I shot myself |
| English product reels | 12 | Same generator, LTR template, different voice |
| News short | 1 | 43 seconds, different project entirely |
| Site tour | 1 | Already built — this one was just scheduled |
The two shot on real footage are worth a sentence, because they broke the template. I own a mechanical keyboard and a power bank and I have actual video of both, and a real product on screen beats a generated card every time. That variant is a full-bleed muted <video> behind a separate audio track, a gradient scrim so the text stays readable over bright frames, and an info panel pinned to the bottom third.
Two templates, one generator, one products.json. The rule I settled on: generated card by default, real footage whenever I actually own the thing.
Two bugs that cost me the most time
Both were silent failures, which is the expensive kind.
A stray HTML file broke the validator. I'd left the template at the project root while iterating. The renderer scans the project for compositions, found two — the real one and my template — and refused to build. The error didn't say "you have two templates." It said something about an ambiguous composition and I stared at it for longer than I'd like to admit. Templates now live in a subfolder.
Rounding scene durations to two decimals produced overlaps. Sentence one ends at 6.184 seconds and sentence two starts at 6.184. Round both to two places and you get boundaries that disagree by a millisecond or two — and two clips on the same track that overlap by any amount at all is a hard error, not a warning.
The fix took one line and is the kind of thing I'd want someone to tell me: use three decimals, and compute each duration as the difference between two already-rounded timestamps rather than rounding the duration itself. Round the points, not the gaps between them.
The worst thing I found wasn't in a video
With the clips rendered, I went to update the queued posts. And because I was already in there, I read the settings on all thirty-six instead of just swapping the media.
Three things were wrong, and the first one would have wasted the entire night:
Auto-add-music was switched on. Every one of the thirty-six posts. The scheduler was going to layer a music bed over the narration I'd just spent the night generating — on the account where I'd specifically decided against music. Nothing would have errored. The posts would have gone out with a voice fighting a soundtrack, and I'd have found out from a comment.
The disclosure line was missing from every caption on one platform. Present on the other one, absent here. Not a policy I'd dropped — a field I'd filled in one place and not the other, weeks earlier, and never re-read.
Six captions were missing the rating and review count that the product data already had. Pure copy-paste drift.
How I verify now, after being wrong about it
I updated all thirty-six posts, then went to confirm the new media had actually attached. My first instinct was to compare filenames: I uploaded reel-07.mp4, does the post reference reel-07.mp4?
It doesn't. The scheduler re-hosts uploaded media on its own storage and assigns a fresh identifier every time you update a post. The filename in the interface tells you nothing about which version is attached.
The only check that means anything is to take the media URL off the post itself and probe the file:
# does the file the post actually points at contain audio?
ffprobe -v error -show_streams -select_streams a \
"<media-url-from-the-post>"
An audio stream, or nothing. That's the test. Thirty-six of them, and that's how I know the queue isn't silent anymore rather than assuming it.
Where I'd spend money instead
This whole batch ran on a free tier and a script, and for volume work I think that's correct. A narrator that tells the build system where every sentence lands is worth more, at thirty clips a night, than a narrator that sounds slightly more human.
The calculation flips completely when the count drops. For a single hero video, a brand's actual speaking voice, anything a viewer will judge by delivery rather than skim — I pay for ElevenLabs, and I cast the voice by ear like it's a small casting session. Different job, different tool, and I don't think either one wants the other's work.
If you're staring at your own silent queue
Three things, in the order they'd have saved me the most time:
- Check whether your content is files or rows. If it came out of a generator, fixing all of it is one change. If it came out of an editor, it's N jobs and you should probably build the generator first.
- Read the settings on scheduled posts, not just the media. The auto-music flag was a bigger problem than anything in my render pipeline, and I only found it because I was already in the screen.
- Verify against the artifact, never the filename. Probe the file the post actually serves. Every layer between you and the platform is free to rewrite what you handed it.
The rendering was the easy part. It always is. The night was long because of a checkbox I'd ticked once and never looked at again.