Back to Blog

Hook Machine: Scoring and Ranking 3-Second AI Ad Openers

By · 7 min read
AI Video Generation Hooks TypeScript FFmpeg

The first three seconds of a UGC ad decide whether a thumb stops scrolling. Everything after is irrelevant if the hook fails. Hook Machine is the tool I built on the video generation platform to generate, score, and rank 3-second ad openers — not full ads, not 15-second spots, specifically the interrupt moment that earns the next twelve seconds of attention.

Twenty-one merged PRs took Hook Machine from research roadmap to production tool. This post covers the hook scorer algorithm, prompt principles, content-aware engine routing, karaoke captions, seed frame dispatch, pre-spend guards, and the four UX workstreams that shaped the editor experience.

A hook is not a shorter ad. It is a different artifact with different constraints: interrupt-first, in-budget, maximum motion, zero setup time.

The 3-second hook problem

Full ad generation optimizes for narrative arc — hook, body, close. Hook generation optimizes for a single metric: pattern interrupt in under three seconds. That changes every design decision:

  • Duration ceiling — hard cap at 3000ms; no scene transitions within the hook
  • Copy budget — 8–15 words max; longer copy guarantees VO overrun
  • Motion priority — static product shots fail; hooks need movement in frame one
  • Ranking — editors generate 5–10 candidates and need best-first ordering, not chronological
Dimension Full ad generation Hook Machine (3s openers)
Target duration 15–60s ≤3s
Primary output Stitched multi-scene video Ranked hook clips
Editor workflow Script → plan → generate → stitch Brief → batch generate → score → pick winner
Cost per candidate $1.50–$4.00 $0.15–$0.80
Success metric Ad completion rate Thumb-stop proxy score

Refocus and infrastructure hardening

The first major PR — feat/hook-openers-refocus-and-hardening — stripped Hook Machine down to 3-second openers only. Earlier prototypes tried to be a general short-form generator; refocus eliminated scope creep and let every subsequent PR optimize for the hook use case. Full infra hardening followed: structured error types, retry policies, Docker build parity, and shared logging with sibling tools.

Roadmap docs (docs/roadmap-from-video-gen-research, docs/roadmap-readiness-and-p1-spec) translated video-gen research into a P1 spec before code shipped. Lessons from prior video-gen failures — documented in docs/lessons-from-video-gen — became explicit guardrails in the spec.

Hook scorer: rank candidates best-first

P1's core feature (feat/hook-scorer-ranking) scores each generated hook and returns candidates sorted best-first. The scorer combines heuristic signals — no ML model training required:

interface HookScoreSignals {
  motionDensity: number;       // optical flow magnitude, normalized 0–1
  copyFit: number;             // spoken duration vs 3000ms ceiling
  firstFrameImpact: number;    // contrast + edge density in frame 0
  audioPeakTiming: number;     // first emphasis within first 800ms
  captionReadability: number;  // chars visible per second
}

function scoreHook(signals: HookScoreSignals): number {
  const weights = {
    motionDensity: 0.30,
    copyFit: 0.25,
    firstFrameImpact: 0.20,
    audioPeakTiming: 0.15,
    captionReadability: 0.10,
  };
  return Object.entries(weights).reduce(
    (sum, [key, w]) => sum + signals[key as keyof HookScoreSignals] * w,
    0,
  );
}

function rankCandidates(hooks: GeneratedHook[]): GeneratedHook[] {
  return hooks
    .map(h => ({ ...h, score: scoreHook(analyzeHook(h)) }))
    .sort((a, b) => b.score - a.score);
}

Motion density is the highest-weight signal because static hooks lose in feed environments. Copy fit penalizes hooks where TTS VO exceeds 3 seconds — a common failure when the LLM writes 25-word hooks for a 3-second slot.

Prompt principles: interrupt-first, in-budget, more motion

feat/hook-prompt-principles codified three rules in the prompt template:

  1. Interrupt-first — open mid-action or mid-sentence, never with setup ("Hi, I'm…")
  2. In-budget — copy must speak in ≤2.8s at target WPM; template enforces word count
  3. More motion — camera or subject movement in the first frame; static = reject
const HOOK_PROMPT_TEMPLATE = `
Generate a 3-second UGC ad hook. Rules:
- INTERRUPT: start mid-action, no greetings or introductions
- BUDGET: exactly {{wordCount}} words, must speak in under 2.8 seconds
- MOTION: subject or camera must move in frame 1 — no static product shots
- PRODUCT: {{productName}} visible within first 500ms
- TONE: {{tone}} — match target demographic
`;

Content-aware routing

Not every hook should hit the most expensive video engine. feat/content-aware-routing inspects the generation brief and routes to the cheapest engine that can handle the content:

Content profile Engine Rationale
Product-only, no human Seedance Cheapest for object motion
UGC presenter, talking head Kling Better lip sync + identity
High motion, lifestyle fal Veo Best motion quality
Policy-sensitive category Replicate fallback Looser content policy

Content-policy self-healing: when an engine rejects content (422 or policy block), the router automatically retries with the next engine in the fallback chain — no editor intervention required.

async function routeWithSelfHeal(
  brief: HookBrief,
  engines: EngineChain,
): Promise<GenerationResult> {
  for (const engine of engines.orderedFor(brief)) {
    try {
      return await engine.generate(brief);
    } catch (err) {
      if (isContentPolicyRejection(err)) continue;
      if (isTransient422(err)) {
        await backoffRetry(() => engine.generate(brief));
        return /* result */;
      }
      throw err;
    }
  }
  throw new AllEnginesRejectedError(brief.id);
}

Pre-spend guards

feat/pre-spend-guards validates before any API spend fires:

  • Copy-length preflight — estimate spoken duration from word count + WPM; block if >3s
  • Clip health check — verify seed frame resolution, aspect ratio, and file integrity

These guards saved meaningful spend during beta — editors pasted long product descriptions that the LLM condensed poorly, producing 5-second VO for 3-second hooks.

Seed frame dispatch chain

P3 (feat/seed-frame-dispatcher) added high-quality seed frame generation with a fallback chain:

  1. Nano Banana Pro (Gemini) — primary, best look-match from product catalog
  2. gpt-image — fallback on Gemini timeout
  3. Seedream — last resort for policy-blocked categories

Seed frames anchor the first video frame. A bad seed produces a bad hook regardless of prompt quality. Dispatch order optimizes for quality first, with automatic degradation on failure.

Karaoke captions

P2 (feat/karaoke-captions) renders word-level timed captions using libass, with drawtext FFmpeg fallback when libass fonts are unavailable in the container:

async function renderCaptions(
  hook: GeneratedHook,
  words: WordSpan[],
): Promise<string> {
  if (await libassAvailable()) {
    return renderLibassKaraoke(hook.videoPath, words, {
      font: 'Inter Bold',
      highlightColor: '#64ffda',
      position: 'bottom-center',
    });
  }
  return renderDrawtextFallback(hook.videoPath, words);
}

Karaoke captions serve double duty: they improve thumb-stop (text movement draws the eye) and feed the caption readability signal in the hook scorer.

Four UX workstreams

Hook Machine's editor UX shipped as four sequential workstreams:

Workstream PR Capability
PR-A: Incremental reveal feat/ux-incremental-reveal Results appear as each candidate completes; live progress + cost tally
PR-B: Persistence feat/ux-persistence-history Recent runs + ?job= URL resume after tab close
PR-C: Winner loop feat/ux-winner-loop Per-variant actions: regenerate, promote, decision log
PR-D: Smarter input feat/ux-smarter-input URL→brief extraction + saved product library

The winner loop pattern

The winner loop is Hook Machine's core retention mechanic. After scoring ranks candidates, the editor:

  1. Promotes the top scorer to "winner" status
  2. Regenerates variants near the winner with mutated prompts
  3. Logs decisions — which hook shipped, why, and what was rejected

Decision logs feed back into prompt tuning. If editors consistently reject high-motion hooks for a product category, the prompt template adjusts motion emphasis downward for that category.

Drive reliability

feat/drive-reliability-rca added RCA tooling for Google Drive upload failures — structured error codes, retry with chunked upload, and admin-visible failure reasons. Hook clips are small (3s ≈ 2–5MB) but batch runs of 10 candidates stress concurrent upload paths.

What I'd do differently

I would integrate the hook scorer into generation — aborting candidates that fail copy-fit preflight before video generation, not scoring them after spend. Post-generation scoring still costs $0.15–$0.80 per rejected candidate.

I would also ship smarter input (PR-D) before winner loop (PR-C). Editors wanted URL→brief from day one; the winner loop was useless without fast brief entry.

The pattern: generate many, rank fast, iterate winners

Hook Machine treats hooks as a search problem, not a generation problem. Generate many cheap candidates, score and rank instantly, let editors iterate on winners. Content-aware routing and pre-spend guards keep the search affordable; karaoke captions and seed frame dispatch keep the winners quality-competitive with manually edited hooks.

Related Articles