Build an AI Video Studio, Part 5: A Comedy Script Skill with a Review Loop and PDF Export

Cover Image for Build an AI Video Studio, Part 5: A Comedy Script Skill with a Review Loop and PDF Export
Video AI5 min read

AI Video Studio with Claude Code + Remotion

🛠 Build it: 1. Setup & your first skill · 2. The script-writing skill · 3. The editing skill · 4. The local AI story skill · 5. The comedy script skill

🎬 Use it: Talking-head video · AI story video · Deadpan comedy video

🔬 Under the hood: Architecture · Subtitles · Jump cuts · Music & SFX · Local AI pipeline

The last skill in the studio writes comedy. Specifically deadpan comedy, a format with a devoted audience on Vietnamese short-video platforms: a performer sits alone at a table with a glass of water or a bag of sunflower seeds, looks straight into the lens with a completely straight face, and explains something trivial — a cheesy pickup line, the urge to make a snarky comment, a midnight conversation with an ant — in the solemn voice of an economics professor, a doctor or a judge. The more absurd the content, the more serious the delivery.

votri-script ("vô tri" roughly means "mindless" — the genre's affectionate name) takes a saying, a pickup line, a phrase or a short story and produces:

  • a five-act comedy script, reviewed and revised with you in chat;
  • once you approve it, a print-ready A4 PDF to hold while performing, with large dialogue text, staging notes, props to prepare, the title and caption to post with, and a ready-to-paste prompt for generating a thumbnail;
  • machine-readable instructions — music per scene, sound effects anchored to specific words, which pauses to keep — so that after you film, the tiktok-video skill from Part 3 edits it automatically.

This part covers two things: building a writing skill with an iterative approval loop and a PDF export, and extending an existing pipeline to a new format without forking it. The full source is in the repository under .claude/skills/votri-script/.

.claude/skills/votri-script/
├── SKILL.md
├── references/
│   ├── craft.md          ← how deadpan comedy works: structure, twist, absurd logic, titles
│   └── format.md         ← the kichban.json contract
├── scripts/
│   ├── new-dir.mjs
│   ├── validate.mjs      ← schema, pacing, deadpan rules, valid music/SFX names
│   └── build-pdf.mjs     ← kichban.json → kichban.html → kichban.pdf (headless Chrome)
└── assets/example/kichban.json

Step 1: Teach the comedy — craft.md

Comedy is the hardest thing in this series to get out of a language model, because the model's instinct is to signal that it's joking. The craft reference opens by naming the single mechanism the whole genre runs on — the gap between a deadly serious attitude and trivial content — and lists the three things that destroy it:

  1. The performer showing they know it's funny: smiling, winking, "just kidding".
  2. Explaining the joke — unless the explanation itself is delivered as a dry technical detail, in which case it's a second punchline.
  3. Silly, giggly dialogue. The lines must sound sincere; the more formal the vocabulary, the better.

Then it gives Claude structure to work with. Five acts, with a time share and a sound plan for each:

Act (part)ShareJobSound
hook10–15%Grab attention in 3 secondsQuiet, reflective lo-fi
lead15–25%Pose the "problem" in grand, official languageLo-fi continues
twist10–20%The line itself — the pickup line, the punchlineMusic cuts out → silence → a sound effect
explain20–30%The serious-but-absurd analysisFunk or a solemn waltz
outro10–15%Instructions + a call to comment, with a specific failure scenarioFunk fades

The twist gets the most detailed staging, because it's where the video earns its views: music stops dead, the line is read slower and lower than anything else, then 1.5–2 seconds of silence with a frozen face and exactly one blink, a snap zoom into the eyes, and a sound effect — a record scratch, crickets, a heartbeat.

The explanation act gets a toolbox, and Claude must use at least two tools from it: fake calculations that take a number from the story and reason about it with total seriousness ("a lipstick lasts six months → a 180-day contract"); formal jargon in the wrong place (economics, medicine, law, science); "other people vs. me" parallels with a pun in the second half; a conclusion delivered like a closing speech; and swapping moral concepts ("snark isn't bad karma, it's self-healing therapy").

The reference also maps each of five input types to a default angle (a proverb → read the first half solemnly, then break the second half; a pickup line → present it as a macroeconomic strategy; an opinion → turn it into a scientific law), lists six hook patterns, explains how to write the thumbnail title without spoiling the twist, and ends with an anti-pattern checklist. Write yours in the same spirit: concrete mechanisms Claude can apply, not adjectives like "make it funny".

Step 2: The contract — kichban.json

A comedy script needs more than a talking-head script: staging for the performer, and instructions for the editor. Each scene carries both:

{
  "part": "twist",
  "file": "s4",
  "label": "The silence + snap zoom",
  "start": 29,
  "end": 35,
  "camera": "Snap zoom into the eyes (extreme close-up).",
  "action": "Face 100% frozen. Silent for exactly 2 seconds, blink exactly once, then speak.",
  "audio": "Record scratch stops the music + intermittent crickets.",
  "line": "…",
  "onScreen": "…",
  "music": "silent",
  "keepPauses": true,
  "zoom": "snap",
  "sfx": [
    { "name": "record-scratch", "at": "start" },
    { "name": "crickets", "at": "pause" }
  ]
}

The fields split cleanly into two audiences:

  • For the performer: camera, action, audio (a description of what they'll hear), voice (delivery notes like "lower, almost a whisper"), line, onScreen, note.
  • For the editor: music (lofi, funky, waltz, romantic or silent), sfx (a name, anchored to start, end, the scene's longest pause, or a specific word of the dialogue), keepPauses (don't cut silence inside this scene), and zoom (snap or push).

One field needs special care: file, the footage name for the scene (s1, s2…). The performer saves clips by these names, so they must never change once printed. When a script is revised, existing scenes keep their numbers and an inserted scene takes the next unused number — scenes are edited by position in the array, footage is matched by file name. The format reference spells this out, because Claude's natural instinct when inserting a scene is to renumber everything, which would silently attach the wrong clips to the wrong scenes.

Top-level fields add the input and its type, the thumbnail text (title ≤ 28 characters, subtitle, shockLine), the performer's persona, the setting, the props to prepare, filming tips, the caption and the hashtags.

Step 3: The validator — including comedy rules

validate.mjs checks the obvious structure (5–12 scenes, contiguous times, acts in order hook → lead → twist → explain → outro with one to three scenes each, 45–120 seconds), and a few rules specific to the genre:

// Deadpan is delivered slowly; above this there's no room left for silence.
const wps = words(s.line) / (s.end - s.start);
if (wps > 5) warn(`${at}: ${wps.toFixed(1)} words/s — too fast for deadpan (≤ 5)`);

// Anything that breaks the straight face.
if (/(haha|hihi|lol|\bkk+\b)/i.test(`${s.line} ${s.action}`)) warn(`${at}: "haha/lol" breaks deadpan`);
if (/cười (tươi|lớn|phá lên|toe)|hào hứng|phấn khích/i.test(s.action)) warn(`${at}: the action asks for laughing/excitement — breaks deadpan`);

// The twist only works with silence and a music cut.
if (!twists.some((s) => s.keepPauses)) warn('no twist scene has keepPauses — the editor will cut the deadpan silence');
if (!twists.some((s) => s.music === 'silent')) warn('no twist scene has music "silent" — no "music cuts out" moment');

And one check that keeps two skills from drifting apart: the list of valid music and SFX names isn't copied into the validator; it's imported from the synthesizer that will produce them:

// Valid names come straight from the editing pipeline's synthesizer, so the two never disagree.
import { MUSIC_NAMES, SFX_NAMES } from '../../tiktok-video/scripts/audio-synth.mjs';

If you add a sound effect to the synthesizer, the comedy skill can use it immediately. If a script names one that doesn't exist, it fails here, in the writing stage, rather than producing a silent gap after you've filmed. When an SFX is anchored to a word, the validator also checks that the word actually appears in that scene's line.

Step 4: The approval loop

Like the AI-story skill, this one stops before producing its expensive output — here the "expense" is a PDF you'll print and perform from. But a comedy script benefits from several rounds of revision, so the stop becomes a loop:

### Step 5 — Present for review (STOP HERE)
Print in this order:
1. Thumbnail: Title / Subtitle / Shock line, one line each.
2. The script table: | Time | Act | Framing & acting | Sound | Dialogue | On-screen text |
   Dialogue in full, never summarized — the user needs to read it to decide.
3. Caption + hashtags.
4. Two alternative directions (one line each), from the brainstorm.
5. A closing question: "Lock this script and export the PDF, or change something?"
Do NOT export the PDF at this step. Do not paste JSON.

### Step 6 — Revise from feedback
Edit kichban.json (keep existing `file` numbers; new scenes take the next free number),
re-validate, print only what changed, ask again. Repeat until the user says ok / lock /
export. If they pick an alternative direction, rewrite in the SAME folder and go back to step 5.

### Step 7 — Export the PDF (only after approval)
validate.mjs && build-pdf.mjs
Then render page 1 to a PNG (pdftoppm -r 60 -png -f 1 -l 1) and look at it, to confirm
Vietnamese text rendered with no empty boxes.

Three details make the loop work:

  • Printing the dialogue in full. A summary of a joke isn't a joke; you can't judge a comedy script without reading the lines.
  • Two alternative directions. The brainstorm step asks Claude for three concepts using three different expert personas (an economist, a doctor, a judge) and keeps the one with the widest gap between tone and content. Offering the other two costs nothing and often turns out to be the better idea.
  • Plain-language feedback. "Make the twist in scene 3 harsher", "cut it to 50 seconds", "use milk tea instead of water", "go with direction 1" — the skill applies them to the JSON, not by rewriting from scratch.

The last line of step 7 is a habit worth adopting: check your own output visually. Claude renders the first page of the PDF to an image and reads it. Missing Vietnamese glyphs show up as empty boxes, which no text-based check would catch.

Step 5: The PDF — build-pdf.mjs

The script builds an HTML page from the JSON and prints it with headless Chrome. HTML and CSS are a much better layout engine than any Node PDF library, and you already have Chrome (Remotion even downloads its own chrome-headless-shell, which the script uses as a fallback).

The layout is designed for someone holding the paper while performing:

  • Page one: the title and subtitle, duration, persona, setting, props to prepare, and a colored timeline bar showing the five acts proportionally.
  • One card per scene: a colored header with the act, the label, the file name to save (s4.mov) and the time range; then framing, acting, sound, and a "the editor will add automatically" line (music, SFX, zoom, kept pauses); then the dialogue in 16 pt bold on a tinted background, readable at a glance; then the on-screen text.
  • Then: deadpan filming tips, the steps to hand the footage to the editor, the post information (title, subtitle, shock line, caption, hashtags) and a thumbnail-generation prompt pre-filled with the text.

A few CSS details matter for print:

@page { size: A4; margin: 14mm 13mm 16mm;
  @bottom-right { content: "Page " counter(page) "/" counter(pages); } }
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } /* keep background colors */
.scene { break-inside: avoid; }                                        /* never split a scene card */

The font is Be Vietnam Pro from Google Fonts, so the printer is given time to load it: --virtual-time-budget=8000. Offline, it falls back to a system font that still has Vietnamese glyphs.

The Chrome invocation has one quirk worth knowing. On macOS, headless Chrome sometimes writes the PDF and then never exits. Rather than waiting for the process, the script polls the output file and treats "the size stopped changing across two checks" as done:

const poll = setInterval(() => {
  const size = existsSync(pdfPath) ? statSync(pdfPath).size : 0;
  if (size > 0 && size === lastSize) finish(true);   // file stopped growing → done, kill Chrome
  lastSize = size;
}, 700);
const timer = setTimeout(() => finish(false, 'Chrome took over 90 s'), 90000);

It also prints with a temporary --user-data-dir, so it never touches the Chrome profile you have open, and if anything fails, it leaves the HTML in place so you can print it from a browser by hand.

Step 6: Plug into the editor without forking it

The comedy script is a different format from script.json, but after filming, it needs exactly the same editing pipeline you built in Part 3. The wrong move would be to copy that pipeline and adapt it. The right move is an adapter: every stage of the editor reads its script through one function, loadScript(), which translates a kichban.json into the shape the pipeline already understands (simplified here; the text-cleaning helpers are in the repository):

const ROLE = { hook: 'hook', lead: 'story', twist: 'punchline', explain: 'story', outro: 'cta' };
const STYLE = { hook: 'hook', lead: 'normal', twist: 'punch', explain: 'normal', outro: 'cta' };

export const convertKichban = (k) => ({
  version: 1,
  id: k.id,
  derivedFrom: 'kichban.json',              // never edit the derived file
  quote: k.input,
  durationSec: k.durationSec, fps: 30, width: 1080, height: 1920,
  audio: { bgm: k.scenes.find((s) => s.music && s.music !== 'silent')?.music ?? 'lofi', bgmVolume: 0.13 },
  edit: { leaveGapSec: 0.3 },               // keep a short beat at every pause
  scenes: k.scenes.map((s) => ({
    id: s.file,                             // footage is matched by file name, not position
    role: ROLE[s.part],
    voiceover: s.line.replace(/\s*\n\s*/g, ' '),
    caption: { text: cleanOnScreen(s.onScreen), highlight: capsRuns(s.onScreen), style: STYLE[s.part], emoji: firstEmoji(s.onScreen) },
    sfx: s.sfx ?? [], music: s.music ?? null, keepPauses: s.keepPauses ?? false, zoom: s.zoom ?? 'none',
    startSec: s.start, endSec: s.end, bRoll: null,
  })),
});

One small trick in there: the on-screen text in comedy scripts uses UPPERCASE for emphasis ("Ăn lẩu 1tr5: LÃNG PHÍ TÀI NGUYÊN!"), so capsRuns() turns runs of uppercase words into the highlight list automatically — no extra field for the writer to fill in.

loadScript() writes the translation to script.json next to the original and regenerates it whenever kichban.json is newer. Then the editor gains a handful of optional behaviors, each triggered only by the new fields:

FieldEditor behavior
edit.leaveGapSec: 0.3Silences are still cut, but 0.3 s of each is kept — a slow, solemn delivery cut to zero sounds rushed
keepPauses: trueNo cuts inside the scene; up to 2.5 s of silence kept before the first word
music per sceneConsecutive scenes with the same track merge; silent spans have no music; 0.25 s fades at changes
sfx with at/wordEffects land on the scene's longest pause or the exact word, on the edited timeline
zoom: "snap" / "push"A 4-frame lurch to 140% toward the eye line, or a slow push to 114%

None of these change how a selfie video is edited. The selfie pipeline from Part 3 still works exactly as before; the comedy format is just a richer input to the same machine.

Step 7: The procedure — SKILL.md

---
name: votri-script
description: Write a 9:16 deadpan comedy script — sitting at a camera, contemplating a
  trivial saying with total seriousness — from a saying, phrase, proverb, pickup line or
  short story. 5 acts (Hook → Lead → Twist → Serious-but-absurd explanation → Outro) plus
  thumbnail title/subtitle/shock line and a caption. Review in chat; export a PDF to
  perform from only after the user approves. Use when the user says "deadpan script",
  "vô tri script", or pastes a saying and wants to film a comedy video.
---

## Steps
1. Identify the input type (quote / pickup line / saying / topic / story) and default angle.
2. Read references/craft.md before writing anything.
3. Brainstorm internally: 3 concepts with 3 expert personas → keep the most mismatched;
   one twist line ≤ 20 words; at least one serious calculation; a concrete failure for the outro.
4. new-dir.mjs → write kichban.json (every scene: file, music, sfx; twist: keepPauses,
   silence scene: zoom "snap") → validate.mjs.
5. Present for review and STOP.
6. Revise until approved.
7. Export the PDF; check page 1 visually.
8. Reply: PDF path; how to film (one clip per scene named s1.mov… into raw/, then
   /tiktok-video <slug>); title/subtitle/shock line with a thumbnail prompt; caption and
   hashtags in a code block.

## Hard rules
- Deadpan is rule #1: the performer never laughs.
- Mock situations, never appearance, region, gender or profession.
- Innuendo is fine; explicit content is not.
- Never invent quotes from real people or named studies. Absurd numbers are fine if
  obviously derived from the story itself.
- Never teach genuinely dangerous behavior.

Step 8: Test it

/votri-script "Anh tặng em cây son, mỗi ngày trả lại anh một ít nhé"

You should get a thumbnail line, a script table with every line of dialogue, a caption, two alternative directions and a question. Ask for one change — "make the outro's failure scenario more specific" — and check that only that scene changed and the file numbers didn't move. Then say "chốt" (lock it) and open the PDF.

For the full loop without performing, generate fake footage from the comedy script and run the editor on it — make-test-footage.mjs reads the script through the same loadScript() adapter:

S=.claude/skills/tiktok-video/scripts
node $S/make-test-footage.mjs <slug> --busy
node $S/preflight.mjs <slug> && node $S/extract-audio.mjs <slug> && node $S/transcribe.mjs <slug> --fallback
node $S/detect-cuts.mjs <slug> && node $S/build-props.mjs <slug> && node $S/render.mjs <slug>

build-props should print the music plan, something like lofi 0.0–18.0s → silent 18.0–29.0s → funky 29.0–60.0s.

The studio is complete

Across five parts you've built four skills and three compositions on one set of foundations:

PatternWhere it showed up
One JSON contract, everything else generatedAll four skills
Validators that encode taste, not just syntaxPacing, structure, verbatim highlights, deadpan rules
Scripts own facts Claude can't knowTimestamps, durations, silences, word timings
Stages with inspectable files between themThe editor's six stages, the story pipeline
Human gates before expensive stepsAI-story approval, comedy-script review loop
Adapters instead of forkskichban.json → script.json

The next three posts switch from building to using: making a talking-head video, an AI story video and a deadpan comedy video, start to finish, from the user's side.