DEV Community

OptiRefine
OptiRefine

Posted on

A short-form video script pipeline that runs on a schedule and needs no LLM API key

Every "automate your faceless YouTube channel" tutorial follows the same shape. Pull an RSS feed,
for loop over the items, POST each one to an LLM with a prompt that says "write a 40-second
TikTok script," write the response to a file. Forty lines. Works on the demo.

Then you schedule it and the parts nobody wrote about start showing up:

  • The model returns prose with no segment structure roughly one time in six, and your downstream parser explodes.
  • Half the RSS items are three-sentence stubs that link to a paywall. There is nothing to write a script about, but the model cheerfully writes one anyway, from nothing.
  • Your scripts run 55 seconds when you asked for 40, because nobody counted words against a speaking rate.
  • You pick three categories, and the first one eats the entire item budget before the other two get a look in.
  • You're paying per token for outputs you throw away.

I ended up building this as an Apify actor
(TikTok & YouTube Shorts Script Generator),
and the interesting part turned out not to be the generation. It was the five problems above. Here
is how each one gets solved, whether you use the actor or write your own.

The output has to be structured, or none of it composes

A script is not a paragraph. It's a timed sequence with distinct jobs per segment, and if your
pipeline treats it as a blob you can't do anything downstream — no b-roll matching, no caption
timing, no per-segment retakes.

So the output shape is a list of segments, each labelled:

{
  "category": "tech",
  "topic": "A hidden phone setting",
  "duration": "40 seconds",
  "platform": "TikTok/YouTube Shorts",
  "wordCount": 96,
  "segments": [
    {
      "label": "HOOK",
      "start": 0, "end": 3,
      "durationSec": 3,
      "speech": "Your phone has been throttling itself since the day you bought it.",
      "visual": "Close-up of hand pulling down settings panel, harsh overhead light"
    },
    {
      "label": "BODY",
      "start": 3, "end": 33,
      "speech": "...",
      "visual": "..."
    },
    {
      "label": "CTA",
      "start": 33, "end": 40,
      "speech": "...",
      "visual": "..."
    }
  ],
  "status": "ok",
  "generatedAt": "2026-08-27T14:02:11.884Z"
}
Enter fullscreen mode Exit fullscreen mode

Two things about that shape are load-bearing.

speech and visual are separate fields. Not one field with stage directions inline. The
moment you want to feed narration to TTS, or match stock footage to a scene description, or
generate captions, you need the spoken words alone with no parenthetical noise in them. Models
will merrily write (cut to close-up) in the middle of the narration if you let them, and then
your voiceover says "cut to close-up" out loud.

start and end exist before there's any audio. The plan is a plan. When you do generate
audio, you get actualStart and actualEnd measured from the file — and the gap between planned
and actual is the most useful debugging number in the whole pipeline. A hook that planned for 3
seconds and measured 5.2 is a hook that will not survive the scroll.

The hook is a per-niche problem, not a prompt problem

This is where most generic pipelines produce unusable output, and it's not fixable by asking
harder in the prompt.

A tech short and a beauty short have structurally different openings. Tech opens on a
counterintuitive claim — your phone has been throttling itself. Beauty opens on a visual
result, before/after, no claim at all. News opens on the stakes. Gaming opens mid-action, usually
mid-sentence. Kids' content opens on a question the viewer can answer out loud.

One prompt cannot do all of those well, which is why single-prompt pipelines produce the
recognisable slop voice — a generic "Did you know that..." opener bolted onto every topic
regardless of niche.

So category is a first-class input, not a topic string. Each of
tech, trends, beauty, fashion, sports, gaming, news, learning, kid friendly,
music, general carries its own persona, hook strategy and segment structure.

RSS mode, which is the version you'd schedule:

{
  "mode": "rss",
  "categories": ["tech", "gaming"],
  "maxItems": 10,
  "duration": "40 seconds"
}
Enter fullscreen mode Exit fullscreen mode

Direct mode, for when you have your own source of topics — a spreadsheet, a scraper, your own
backlog:

{
  "mode": "direct",
  "items": [
    {
      "category": "tech",
      "topic": "A hidden phone setting",
      "data": "Most phones ship with a battery saver that is off by default. Enabling it..."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Direct mode is the one that matters if you're building something bigger. It makes this a stage
in a pipeline rather than the whole pipeline — anything that can produce {category, topic, data}
can feed it.

The multi-category budget trap

Ask for ["tech", "gaming", "news"] with maxItems: 10 and the naive implementation walks the
tech feed first, produces ten tech scripts, and stops. You asked for three categories and got one.

The fix is to divide the budget across categories and interleave collection rather than
draining feeds in order. It's an obvious bug once you've seen it and an easy one to ship, because
it looks completely fine when you test with a single category.

Thin sources produce hallucinated scripts

An RSS item with a 40-character summary and a paywalled link contains no information. Feed it to a
model with "write a script about this" and the model will write one — confidently, and entirely
from its own priors. That's a fabrication with your channel's name on it.

The gate is boring and it works: require a summary of at least ~500 characters, or 200+
characters of extracted body text, and skip the item otherwise.
Thin and paywalled pages get
dropped rather than embellished.

Whatever you build, put a content-length floor somewhere in it. It is the difference between a
pipeline that summarises and one that invents.

Failed generations are a first-class output, not an exception

Small models return unparseable output some of the time. Not often, but often enough that at
scheduled scale you will get some every week. The wrong responses are to retry forever, to crash
the run, or to silently drop the item.

The right one is to route it: unparseable generations land in a separate failed-generations
dataset with "status": "unparseable", a reason, and the raw text that came back — and they
aren't billed
, because you didn't get a script.

That last part is worth designing for even in your own build. If you're paying per token, you're
paying for garbage output at the same rate as good output, and you have no incentive signal
telling you the prompt is degrading. Separating billable success from non-billable failure makes
prompt regressions visible instead of just expensive.

The failed-generations dataset is also the best prompt-debugging corpus you'll get. Read twenty
of them and you'll usually find one specific phrasing in one category that reliably derails the
model.

Generation without an API key

The actor runs an embedded Llama 3.1 8B by default. No OpenAI key, no Anthropic key, no
separate billing relationship — you run it and it generates.

That is not because an 8B model is better. It isn't. It's that short-form scripts are close to the
ideal task for a small model: ~100 words of output, rigid structure, strong per-category
scaffolding around the prompt. Almost all of the quality here comes from the structure, and very
little from raw model capability. When the scaffolding does most of the work, model size stops
being the bottleneck.

If you disagree, the escape hatch is documented: deploy the worker in worker/ to your own
Cloudflare account and pass workerUrl and workerSecret as a pair — a URL without a secret
is rejected, and the built-in generator's own secret is never forwarded to a custom worker.

Voiceover, if you want it

Add an ElevenLabs key and each segment gets an audioUrl plus measured timings:

{
  "mode": "rss",
  "categories": ["tech"],
  "maxItems": 5,
  "elevenLabsApiKey": "<your key>",
  "voiceId": "21m00Tcm4TlvDq8ikWAM",
  "ttsModelId": "eleven_turbo_v2_5",
  "ttsConcurrency": 2
}
Enter fullscreen mode Exit fullscreen mode

Two things to know. ElevenLabs bills your account directly, not through Apify — the run log
estimates the character count up front so you can see the damage before it happens. And
ttsConcurrency defaults to 2 because the free tier will rate-limit you above that; raise it only
if you're on a paid plan.

What to expect from the output, honestly

An 8B model producing 100 words against a rigid template gets you a solid first draft and not a
finished script. In practice the parts that hold up and the parts you rewrite are consistent:

Usually fine as-is: the segment structure and timing, the visual cues (they're generic but
they're a real shot list, which is more than a blank page), and the CTA.

Usually needs a pass: the hook. It's the highest-leverage seven words in the whole video and
it's the thing a small model is weakest at, because a good hook depends on knowing what your
specific audience already believes. Expect to rewrite most of them. That's fine — rewriting one
line beats writing five.

Watch for: claims stated more confidently than the source supports. The content-length gate
stops the worst of it, but a summary that says "researchers suggest" can come out the other end
as "researchers proved." Read the speech fields against the sourceUrl before you record
anything factual.

The right mental model is that this replaces the blank page and the timing math, not the editorial
judgement. If you generate ten and keep three, it's working correctly.

Putting it on a schedule

The whole point is that you don't run this by hand. In Apify, a schedule is a cron expression on
the actor — 0 7 * * * for a daily 7am batch — and the output accumulates in a dataset you can
pull from anywhere:

curl -s "https://api.apify.com/v2/datasets/$DATASET_ID/items?clean=true&format=json" \
  | jq -r '.[] | select(.status == "ok")
           | "\(.topic)\n" + (.segments[] | "  [\(.label)] \(.speech)")'
Enter fullscreen mode Exit fullscreen mode

A sensible daily loop looks like: generate 10 in the morning → you skim and keep 3 → those 3 go to
a shot list → record or assemble → publish. The pipeline's job is to make the skim cheap. It is
not to publish unattended, and I'd argue strongly against wiring the output directly to an upload
API. The value is in cutting the blank-page cost of the first draft, not in removing the human
from the loop.

Limits worth knowing before you build on it

  • maxItems caps at 50 per run. Shared inference allowance; run more often rather than bigger.
  • generationConcurrency defaults to 5 (max 20), ttsConcurrency to 2 (max 10).
  • There's a run deadline (runDeadlineSeconds, default 210) and a per-item timeout. Long batches with TTS on will hit them — split the work rather than raising both to the ceiling.
  • RSS quality is the ceiling on output quality. Use feedOverrides to point at feeds that publish real summaries. Garbage in still applies, and no amount of prompt work fixes a feed that publishes headlines only.

The actor is
TikTok & YouTube Shorts Script Generator
— pay-per-event, and unparseable generations aren't charged.

If you're building your own instead: the four things worth copying, in order, are the
segment-level output shape, the per-category hook strategy, the content-length gate on the source,
and the separate failure dataset. The model call is the least interesting part of the whole thing.

Top comments (0)