All systems

Live · in production since

beBetterStudio

A content pipeline that generates short-form video, publishes it across four networks on a schedule, and measures which decisions actually worked.

Design, build and operations · 2026 · Node.js, TypeScript, React, FFmpeg, Gemini, n8n, PostgreSQL, AWS EC2, Docker

The problem I thought I had

Publishing short-form content by hand does not scale. Pick an image, pick a line, lay out the text, render, upload to four networks, write four captions, schedule. Twenty minutes per post, every day, forever.

So I built the machine. It worked. And then the account started dying anyway, which turned out to be the actually interesting problem.

The system

A web app plus an automation layer. The app holds a bank of images and a bank of phrases; each is analysed and vectorised so that pairing an image to a line is a similarity query, not a judgement call I have to make at 11pm.

From there: the composition renders through FFmpeg into a 9:16 video, an audio track is picked by matching mood and energy tags, and the piece goes into a queue with its own publishing cadence. n8n drains that queue and posts to YouTube Shorts, Instagram Reels, Facebook Reels and TikTok, with the platform-specific dance each one demands, including Facebook’s three-phase upload and tokens that expire and have to rotate themselves.

There is also a carousel mode: a script is drafted, slides are generated in series with the cover first as a style reference for the rest, and the whole set publishes natively.

Step 07 is the one that did not exist for the first three months, and it is the reason this case study has a plot.
  1. 01 Banks

    Images and phrases are analysed and vectorised on the way in.

  2. 02 Pairing

    Matching an image to a line is a similarity query, not a decision I make at 11pm.

  3. 03 Render

    FFmpeg composes the 9:16 video with the text burned into the frame.

  4. 04 Audio

    A track is picked by matching mood and energy tags against the phrase.

  5. 05 Queue

    The piece waits its turn under a cadence I control, not a posting spree.

  6. 06 Publish

    n8n drains the queue and handles the specific dance each platform demands, including Facebook’s three-phase upload and tokens that rotate themselves.

    YouTube Shorts, Instagram Reels, Facebook Reels, TikTok

  7. 07 Measure The missing step

    Daily insight snapshots are joined back to the recipe that produced each post.

When the numbers started falling

The pipeline was producing. The account was not.

Median reach per piece
AprilMayJuneJuly
3,8402,3101,138385

The easy explanation is the algorithm, and it is the explanation I wanted. Two other numbers said otherwise:

Neither of those depends on how many people the platform shows you to. They depend on what happens once someone is already watching. The reach was falling because the content had gotten worse, and I could not see it because I had no idea which decisions produced which results.

Every post has a recipe: which phrase, which image, which mood, which format, what time. That recipe lived in my database. The performance lived in the platform’s API. Nothing connected them.

Building the link for new posts was easy. The problem was the 40 posts published before any of this existed: the ones covering the entire decline. The plan assumed they were lost.

They were not. The videos have the phrase burned into the frame, which means the thumbnail literally contains it. The caption does not: the caption is a rewrite, which is exactly why matching by lexical overlap kept landing at 25-50% even for posts that did correspond.

So I read the thumbnails with vision and compared phrase against phrase. Of 40 orphan posts, 39 were recovered. One remains. For contrast, the embedding pass I tried first recovered exactly one.

The rule that made the reconciliation trustworthy was refusing to guess: four passes, strongest signal to weakest, and when confidence was not there the post stayed unlinked. A wrong link poisons every conclusion downstream and leaves no trace. An orphan is visible and fixable.

What the data said

With performance joined to recipe, one variable moved almost exactly in step with the decline. Phrases attributed to a named author:

April 17% → May 32% → June 91% → July 83%

I watched the ten extremes: five best, five worst. The ones that traveled name somebody recognisable and land on a turn. The flat ones are quoted philosophers in quotation marks.

That was a hypothesis with evidence behind it, not a conclusion. So it became a controlled test: ten new pieces written in the account’s own voice, tagged as a distinct cohort, interleaved with quotes, and measured on skip rate and watch time, not on reach, because reach is the number that lies.

The result, after fixing the instrument

Before reading the result I had to repair the measurement. One insert path was not passing the media type, so the test posts were being queried with the metric set for images: the one without skip rate or watch time. No error anywhere; the numbers were simply not there. It was recoverable only because those two metrics are lifetime values on the platform side: collecting them late still gives the real figure.

Controlled test: control against own voice
Skip rate (median)Watch time (median)
Control 51.5%4.88s
Own voice 47.1%6.64s
p p = 0.019, lower is betterp = 0.003, higher is better
Control: 16 posts, July 1-27. Own voice: 10 posts. Medians, with a permutation test.

A permutation test gives p = 0.019 for skip rate and p = 0.003 for watch time. Watch time went back to April’s level, when the account was working, and median views roughly tripled the following week.

The honest caveat: the ten test phrases also shared a two-beat structure (a setup and a turn), so the test cannot separate who is speaking from how the line is built. Splitting them would have meant publishing ten deliberately weaker pieces during the only week the account was growing again. I chose not to, made the two-beat structure a brand rule, and wrote the open question down as open.

From my machine to its own server

In August the app left my laptop. It now runs on its own AWS instance with Docker behind Caddy and TLS, on PostgreSQL instead of SQLite, with Google sign-in restricted to an allowlist and no SSH port open: access goes through AWS Systems Manager.

The server also generates whole batches on its own. To do that without a browser it measures text with the same font file FFmpeg renders with, so line breaks match the editor’s preview in 705 of 708 cases (118 phrases × 6 presets), and in every one of the brand preset. The brand rules (two-beat structure, third person) are columns the batch planner queries, so the bot cannot propose a line that would not get published.

Where it stands

Running, at three pieces a day, and every piece still goes through human approval before it is published. As of September 12, 2026: 124 pieces with metrics, 755,151 views in total, and 127,404 on the best single piece. Median reach per piece is 1,687. I quote the median rather than the average (4,313) because two pieces pull the average up.

The part worth taking away is not the renderer. It is that a content pipeline without measurement is a machine for producing more of whatever you were already doing wrong, faster.

Need something like this running?

Tell me what you need running. I reply in writing, with questions, a rough plan and a number.