DEV Community

Jasper Smith
Jasper Smith

Posted on AI-assisted

I stopped buying a domain for every viral meme. Here's the config-driven AI video hub I built instead

For a few months I did something a lot of indie hackers do: every time an AI video meme blew up on TikTok, I bought a domain, cloned my last project, swapped the copy and shipped a one-page "make your own X video" site.

It works — for about two weeks. Then the meme dies, the domain renews, and I'm maintaining five near-identical codebases with five Stripe setups, five Google OAuth clients and five databases.

So I rebuilt it as one site where a trend is just a config entry plus a few media files: AI Trend Video. Upload one or two photos, pick a trend, get a short video of you doing it. This post is about how it's wired, and the few things that bit me.

The core idea: a trend is data, not a site

Every trend is a template: a short, silent reference clip, a prompt, and some rules. The user's photo replaces the person (or car, or product) in the clip.

export type Trend = {
  id: TrendId;
  path: string;              // landing page, e.g. /ai-rap-video
  photos: 1 | 2;             // one per replaced performer, left first
  seconds: number;           // must match the reference clip
  ratios: readonly AspectRatio[];
  audio: 'original' | 'generate';
  model: VideoModel;
  reference?: 'video' | 'none';
  prompt: (ratio: AspectRatio, seconds: number) => string;
};
Enter fullscreen mode Exit fullscreen mode

Adding a new meme is now: drop the clip into assets-src/incoming/, run one script that trims/encodes it and publishes it to R2, add a registry entry, write the page copy. No new deploy target, no new auth, no new billing. All trends share one sign-in, one credit wallet and one video history — which also means someone who came for one meme can try the next one with credits they already have.

A trend whose reference clip or soundtrack is missing simply shows as "coming soon" and can't be charged for. That one rule saved me from a whole class of "user paid, nothing happened" bugs.

Stack

  • Next.js 15 on Cloudflare Workers via OpenNext
  • D1 (SQLite) for users, credits, jobs, subscriptions
  • R2 for private user uploads (signed URLs) and a public CDN bucket for samples/references
  • Stripe for one-time packs and monthly/yearly plans
  • Seedance 2.0 reference-to-video for generation, plus an ffmpeg API for audio merging
  • A Worker cron every 5 minutes for recovery

It all runs on one Worker. Monthly infra cost is basically zero until people generate videos.

The generation pipeline

  1. The client uploads each photo to /api/uploads (private R2).
  2. /api/generate reserves credits in D1 first, in the same write that creates the job. No credits, no job.
  3. One provider task: photos as reference images, the trend's silent clip as reference video, the trend prompt.
  4. The provider calls our webhook when it's done. We download the result into R2 so users never depend on the provider's temporary URL.
  5. If anything fails, the reserved credits go back — exactly once.

Step 5 is where most of the code actually lives. Webhooks get missed, arrive late, or arrive twice. So:

  • The cron job polls every in-flight job older than a few minutes and asks the provider directly. Missed callbacks get recovered without anyone noticing.
  • A refund is a state transition on the job row, not a side effect. A job can move to refunded once; a late "failed" callback for a job the cron already recovered must not refund it.
  • I originally auto-retried failed generations once. I removed it: a retry doubles the provider bill on exactly the inputs that tend to fail again. Now a failure is refunded and the user decides whether to try another photo.

Gotcha #1: let the model do motion, not music

For meme trends, the audio is the meme. Video models can generate sound, but a recognizable song? No.

So each trend declares audio: 'original' | 'generate'. For 'original' trends the prompt explicitly asks for a silent video, and after generation a separate ffmpeg step merges the original soundtrack from 00:00 before the result is saved. Trends like the flying-car stunt use 'generate' because engine and crowd noise is something the model is genuinely good at.

Gotcha #2: fetch(..., { redirect: 'error' }) throws on Workers

This one cost me a launch evening. Every video with an original soundtrack failed at the save step, got retried three times, and was refunded as "could not be saved".

The cause: I was downloading the soundtrack with

await fetch(soundtrack, { redirect: 'error' });
Enter fullscreen mode Exit fullscreen mode

which is valid in browsers and Node, but Cloudflare Workers only accept 'follow' or 'manual' and throw at runtime otherwise. Local dev didn't catch it.

The fix is redirect: 'manual' (a redirect then fails the !res.ok check, which is what I wanted anyway), plus a dumb test that greps Worker code so it never comes back:

test('Worker code never uses fetch redirect "error"', () => {
  for (const file of files)
    assert.doesNotMatch(readFileSync(file, 'utf8'), /redirect\s*:\s*['"]error['"]/, file);
});
Enter fullscreen mode Exit fullscreen mode

If you deploy to Workers: this is the kind of difference you only find in production. Grep for it now.

Gotcha #3: one site means the pages have to earn their traffic

With one domain per meme, the domain name did half the SEO work. With one hub, each trend page has to stand on its own. Every trend page now has: an answer-first summary, trend-specific steps (not shared boilerplate — identical steps on every page is duplicate content), a short sourced history of where the meme came from, photo tips, an honest "what this is and isn't" section, and a real FAQ.

It's more writing per trend, but it's writing instead of a new deployment.

Custom templates

The second thing people kept asking for: "can I use my clip?". Subscribers can now upload their own short video, pick the trim range, and it becomes a private template that only they can use — processed automatically, keeping the clip's original audio. Under the hood it's the same Trend shape, just stored per user instead of in the registry.

What I'd tell myself three months ago

  • Don't polish a site before it has orders. My nicest-looking sites made the least money.
  • A shared "credits + history + auth" core is worth more than any single landing page.
  • Treat webhooks as hints, not truth. Poll.

If you want to see it working, the current trends are on aitrendvideo.net — the AI rap video one is the most popular right now.

I'd love feedback, especially from anyone running video/AI workloads on Workers: how are you handling long-running provider jobs and idempotent refunds?

Top comments (1)

Collapse
 
alexcodebytes profile image
Oleksandr •

The redirect: 'error' throwing on Workers is such a classic edge-runtime trap. It works perfectly in local Node, passes all your tests, and then explodes in production. The grep test to prevent it from coming back is a brilliant piece of defensive engineering.

Your point about "webhooks as hints, not truth" is the most important architectural lesson here. Treating the polling cron as the source of truth and the webhook as just a fast-path notification is the only way to build reliable video generation pipelines.

One question on the idempotent refunds: when the cron job recovers a missed callback, how do you handle the race condition if the provider's webhook arrives a few seconds after the cron already marked the job as refunded or completed? Do you use optimistic locking on the D1 row, or does the state transition just silently ignore the late webhook?