Claude Skill

render-chatgpt-chat

Assemble a ChatGPT chat-reveal video ad from a thread + timeline JSON — one continuous Playwright recording of a ChatGPT mobile chat (user types with the iOS keyboard up → taps send → keyboard slides down + header cluster swaps in one beat → one gray loading dot → the assistant a

LLM Mart · 0 points · 6 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download gooseworks-ai-goose-skills-skills_ads_capabilities_render-chatgpt-chat-e1592ee.zip · 57 KB
Part of gooseworks-ai/goose-skills — 44 skills

Install

skills CLI npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-chatgpt-chat
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
Git git clone https://github.com/gooseworks-ai/goose-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gooseworks-ai/goose-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

render-chatgpt-chat

The free renderer for the chatgpt-chat video ad format — the "I just asked ChatGPT…" creative, where someone asks ChatGPT a question and the streamed assistant answer is the punchline (the brand surfacing as the natural response). Deterministic Playwright + ffmpeg assembly; no generative video of the UI, so the bubble text and streamed answer stay pixel-crisp.

This is the ChatGPT sibling of render-imessage-chat. Reach for this one when ChatGPT is the more credible host for the answer; reach for iMessage when the punchline is a peer's reaction in a DM. The template recipe (DB) supplies the per-brand thread + timeline + end_card config and gates the paid music call (music bed → create-music-elevenlabs) to its own capability.

What it renders

One continuous take — never scene-by-scene (every reload flickers):

  1. User types in the composer with the iOS keyboard up (composer-type).
  2. Send-tap is ONE beat — the user bubble pops, the keyboard slides down, and the header right-cluster swaps (personPlus/dottedCircle → edit/more) all on the same t. Never sequence them across frames.
  3. One gray loading dot holds ~500ms (never three — three reads as iMessage typing, wrong app), silently (no SFX on the dot).
  4. The assistant answer streams in word-by-word (stream-words, ~7 wps) with a soft opacity ramp; the conversation auto-scrolls to keep it in view.
  5. Crossfade to a designed end card (wordmark + ⭐ proof row + trust trio + CTA pill) and mux a ducked music bed → master MP4.

The chat records at the ChatGPT-native ~9:19.5 (default 750×1624) to match a real iPhone screen recording. Never stretch the chat to a different aspect ratio — the end card is scaled-to-fit + padded to the chat's dimensions in stitch, so the chat is never touched.

Run

cd scripts && npm install            # once — installs Playwright
npx playwright install chromium      # once
node record-chat.js     --config config.json --out-dir <work>   # → master-chat.mp4 + .sfx.json
node render-end-card.js --config config.json --out-dir <work>   # → scene-end-endcard.mp4
bash stitch.sh --chat <work>/master-chat.mp4 --end <work>/scene-end-endcard.mp4 \
     --sfx <work>/master-chat.sfx.json --out <work>/master-final.mp4 \
     --pad-color "#ffffff" [--music <work>/music-bed.mp3] [--also-1x1]
  1. record-chat.js — reads config.json (thread + timeline + geometry), renders the bundled create-chatgpt-mockup HTML once with every message pending, walks the timeline on requestAnimationFrame inside the page, records it as one continuous MP4, and emits the deterministic SFX cue list.
  2. render-end-card.js — fills end-card.template.html from config.end_card (wordmark/logo_svg, stars, proof, trust trio, CTA, colors) → still MP4. This is the SAME generic end card as render-imessage-chat (copied verbatim).
  3. stitch.sh — normalizes the end card to the chat's dimensions, crossfades chat → end card, layers the subliminal ChatGPT SFX, optionally ducks a music bed under it, and optionally derives a 1:1 crop. All FREE ffmpeg. Pass --pad-color = end_card.bg (default #ffffff, ChatGPT light mode) so the pad under the end card is seamless.

The chat body: bundled create-chatgpt-mockup

The ChatGPT chat HTML comes from create-chatgpt-mockup (its generate.js + templates/ produce the light-mode ChatGPT iOS HTML — status bar, header, message rows, streaming word-spans, composer, and the inline iOS keyboard). Those files are bundled into scripts/mockup/ so this capability renders the chat body standalone — no sibling fetch of create-chatgpt-mockup is required. record-chat.js does require('./mockup/generate.js').

The keyboard is inlined by the mockup (renderKeyboard) — no separate keyboard atom.

Timeline events (consumed by record-chat.js)

Kind Meaning
composer-type { text, dur_sec } — type into the composer. SFX = one key-tap per word.
composer-clear Wipe the composer instantly (fire at send-tap).
keyboard-show / keyboard-hide Slide the iOS keyboard up / down.
send-tap Pulse the send button. SFX = send-tap.
pop { target: <msg-id> } — reveal a message row.
header-swap { value: "alt" } — swap the header right-cluster.
loading-dot-show / loading-dot-hide { target: <dot-id> } — the single gray dot.
send-state { value: "streaming"\|"active" } — composer send-button state.
stream-words { target, dur_sec, wps } — reveal the assistant answer word-by-word. SFX = stream-tick every 12 words + response-done at the end.
scroll-to { target, dur_ms } — smooth-scroll a row into view.

See scripts/config.example.json for the canonical thread + timeline (the "one beat" send-tap and the streamed list answer are both wired there).

Contract

  • FREE assembly: Playwright record + ffmpeg composite/mux + the bundled SFX. No AI-rendered text — the bubbles, the streamed answer, and the end-card copy are all real HTML/PIL, never invented by a model.
  • The recipe (DB) supplies the per-brand config: the thread (light-mode ChatGPT, assistant message set stream: true), the timeline, the end_card (prefer a real logo_svg wordmark), and an optional music bed.
  • SFX are subliminal by design (ChatGPT has no native chime): key-tap -28dB, send-tap -20dB, stream-tick -32dB, response-done -22dB, and never a cue on the loading dot. Set "sfx": false in the config to ship the chat silent.

Gaps / routing notes

  • Music bed is an input, not generated here — the recipe gates it to create-music-elevenlabs (paid, proxy-routed, billed to the Ads agent) and passes the file into stitch.sh --music.
  • Bundled SFX are synthesized stand-ins. The original four wavs (key-tap/send-tap/stream-tick/response-done) were lost from Git LFS (the objects 404 on the server), so assets/sfx/*.wav are freshly synthesized subliminal clicks/ticks. They work as-is; swap in real wavs (same filenames) for tuned SFX.
  • Portability: everything runs from the fetched /tmp/gooseworks-scripts/render-chatgpt-chat/scripts/… — the chatgpt-mockup generator + templates are bundled under scripts/mockup/, and the generic end card is bundled under scripts/. No /Users/… or repo-relative paths, and no required sibling fetch.
  • Requires ffmpeg/ffprobe on PATH and Playwright Chromium (npx playwright install chromium).

Self-QC (per project rule — always /watch the master)

  • Keyboard is up the whole time the user types, and slides down only on the send-tap beat (never visible while the answer streams).
  • Send-tap is one beat: user bubble + keyboard-down + header-swap on the same frame.
  • Exactly one gray loading dot for ~500ms (not three), and no SFX on the dot.
  • The answer streams word-by-word, left-to-right / top-to-bottom, not all-at-once.
  • No OpenAI spiral logo above any assistant title (the spiral is empty-state only).
  • No micro-flicker / scene cuts; the end-card pad color matches end_card.bg.
Files (goose-skills)
  • assets
    • sfx
      • key-tap.wav 3.1 KB · in bundle
      • response-done.wav 13.9 KB · in bundle
      • send-tap.wav 7.8 KB · in bundle
      • stream-tick.wav 2 KB · in bundle
  • scripts
    • mockup
      • templates
        • chat.css 16.6 KB · in bundle
        • icons.js 11.5 KB
          // SVG icon snippets used by the ChatGPT chat templates.
          // Goal: match the real ChatGPT mobile (iOS) chrome 1:1 — stroke weights,
          // proportions, and corner radii are tuned to the reference screenshots.
          
          const ICONS = {
            // Hamburger / sidebar toggle — two short stacked lines with the bottom one
            // recessed (matches ChatGPT iOS: the icon reads as "≡" with bottom line shorter).
            hamburger: `<svg width="30" height="22" viewBox="0 0 22 16" fill="none"><path d="M2 4 H20" stroke="#0D0D0D" stroke-width="2.2" stroke-linecap="round"/><path d="M2 12 H14" stroke="#0D0D0D" stroke-width="2.2" stroke-linecap="round"/></svg>`,
          
            // Down chevron next to "ChatGPT 5.1" model name.
            chevronDown: `<svg width="18" height="12" viewBox="0 0 13 8" fill="none"><path d="M1.5 1.5 L6.5 6 L11.5 1.5" stroke="#0D0D0D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
          
            // Right chevron used when model name is just "ChatGPT >"
            chevronRight: `<svg width="11" height="17" viewBox="0 0 8 13" fill="none"><path d="M1.5 1.5 L6 6.5 L1.5 11.5" stroke="#9B9B9B" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
          
            // Top-right person-plus (invite/share-with-people).
            personPlus: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#0D0D0D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="9.5" cy="8" r="3.5"/><path d="M3.5 19c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M18 6v6 M15 9h6"/></svg>`,
          
            // Top-right dotted circle (voice / siri-like indicator).
            dottedCircle: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#0D0D0D" stroke-width="1.8" stroke-linecap="round" stroke-dasharray="2 3"><circle cx="12" cy="12" r="9.5"/></svg>`,
          
            // Edit / new-chat (pencil-in-square) used in some references.
            editPencil: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#0D0D0D" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20h4l10.5-10.5a2.5 2.5 0 0 0-3.5-3.5L4.5 16.5z"/><path d="M14 7l3 3"/></svg>`,
          
            // Three-dot menu in upper-right.
            moreDots: `<svg width="30" height="30" viewBox="0 0 24 24" fill="#0D0D0D"><circle cx="5.5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.5" cy="12" r="1.7"/></svg>`,
          
            // Composer "+" (left side).
            plus: `<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="#0D0D0D" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>`,
          
            // Composer mic.
            mic: `<svg width="24" height="30" viewBox="0 0 24 28" fill="#0D0D0D"><path d="M12 16a3.5 3.5 0 0 0 3.5-3.5V6a3.5 3.5 0 0 0-7 0v6.5A3.5 3.5 0 0 0 12 16z"/><path d="M19 12.5a1 1 0 0 0-2 0 5 5 0 0 1-10 0 1 1 0 0 0-2 0 7 7 0 0 0 6 6.93V22h-2.25a1 1 0 0 0 0 2h6.5a1 1 0 0 0 0-2H13v-2.57A7 7 0 0 0 19 12.5z"/></svg>`,
          
            // Composer send-arrow (up). Sits inside the round send button.
            sendArrow: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19 V5 M5 12 L12 5 L19 12"/></svg>`,
          
            // ChatGPT spiral logo (assistant icon next to a response).
            // Simplified geometric stand-in for the OpenAI mark — works at small sizes.
            openaiSpiral: `<svg width="22" height="22" viewBox="0 0 41 41" fill="none"><path d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.49932C30.0864 3.63105 27.9098 3.40828 25.8244 3.86289C24.8842 2.80055 23.7288 1.95057 22.4347 1.36825C21.1407 0.785923 19.7373 0.484487 18.3174 0.485103C16.1825 0.480239 14.0995 1.1514 12.3672 2.40158C10.6349 3.65176 9.34433 5.41912 8.6795 7.44941C7.28685 7.73495 5.97032 8.31504 4.81725 9.15054C3.66417 9.98603 2.70047 11.0578 1.98744 12.2937C0.91691 14.1419 0.46128 16.2832 0.687283 18.4071C0.913286 20.5311 1.81051 22.528 3.24024 24.1101C2.79189 25.4566 2.63634 26.8833 2.78409 28.2948C2.93184 29.7063 3.37938 31.0699 4.09676 32.2945C5.16016 34.1467 6.78457 35.6131 8.73546 36.4814C10.6864 37.3496 12.8629 37.5724 14.9483 37.1178C15.8884 38.18 17.0438 39.0301 18.3379 39.6124C19.6319 40.1948 21.0355 40.4962 22.4554 40.4956C24.5917 40.4969 26.6753 39.8251 28.4082 38.5743C30.1411 37.3236 31.4316 35.5553 32.0953 33.5239C33.4879 33.2383 34.8044 32.6583 35.9575 31.8228C37.1105 30.9873 38.0743 29.9156 38.7873 28.6797C39.8564 26.8321 40.3105 24.6925 40.0838 22.5701C39.8571 20.4476 38.9611 18.4523 37.5324 16.8707ZM22.4567 37.8505C20.7027 37.853 19.0043 37.2389 17.6571 36.1147C17.7181 36.0814 17.8247 36.0234 17.8942 35.9805L25.1932 31.7672C25.3759 31.6633 25.5278 31.5126 25.6336 31.3305C25.7395 31.1485 25.7953 30.9416 25.7956 30.7308V20.4429L28.8796 22.2233C28.8961 22.2316 28.9105 22.2437 28.9214 22.2587C28.9323 22.2737 28.9395 22.2911 28.9425 22.3095V30.8264C28.9401 32.6797 28.2 34.4561 26.8849 35.7669C25.5698 37.0778 23.7891 37.8154 21.9329 37.8222L22.4567 37.8505ZM6.39281 30.9716C5.51307 29.4524 5.19639 27.6748 5.49989 25.9447C5.55891 25.9802 5.66258 26.0444 5.7359 26.0863L13.0349 30.2997C13.2168 30.4063 13.4242 30.4625 13.6354 30.4625C13.8465 30.4625 14.054 30.4063 14.2358 30.2997L23.1442 25.1551V28.7159C23.1453 28.7345 23.1419 28.7531 23.1342 28.7701C23.1265 28.7872 23.1148 28.8022 23.1 28.8138L15.7221 33.0758C14.1187 34.0008 12.2123 34.2515 10.4234 33.7732C8.63446 33.295 7.10978 32.1259 6.19785 30.5234L6.39281 30.9716ZM4.47084 14.8784C5.34741 13.3535 6.74305 12.1929 8.40554 11.6094C8.40554 11.679 8.40159 11.8004 8.40159 11.8854V20.3081C8.40044 20.5165 8.45487 20.7215 8.55925 20.9023C8.66362 21.0832 8.81434 21.2337 8.99632 21.3389L17.9078 26.4807L14.823 28.2611C14.8075 28.2706 14.7901 28.2761 14.7723 28.2772C14.7544 28.2782 14.7365 28.2748 14.7203 28.2671L7.34122 24.0014C5.74174 23.0791 4.57391 21.5535 4.09405 19.7621C3.61419 17.9707 3.86074 16.0641 4.79922 14.4543L4.47084 14.8784ZM30.7269 21.0157L21.8146 15.8854L24.8995 14.1061C24.915 14.0965 24.9324 14.0911 24.9502 14.0901C24.9681 14.0891 24.986 14.0925 25.0022 14.1001L32.3812 18.3617C33.5273 19.024 34.4666 19.9881 35.0974 21.1497C35.7283 22.3114 36.0258 23.6249 35.9568 24.9437C35.8879 26.2624 35.4552 27.5377 34.7066 28.6275C33.958 29.7174 32.9237 30.5825 31.7146 31.1289C31.7146 31.0588 31.7146 30.9379 31.7146 30.8533V22.4306C31.7164 22.2222 31.6627 22.0171 31.5588 21.836C31.4548 21.6549 31.3046 21.504 31.1228 21.3984L30.7269 21.0157ZM33.7965 16.5276C33.7378 16.4915 33.6342 16.4278 33.5605 16.3859L26.2615 12.1726C26.0793 12.066 25.8718 12.0098 25.6605 12.0098C25.4493 12.0098 25.2418 12.066 25.0596 12.1726L16.1591 17.3172V13.7563C16.1581 13.7378 16.1614 13.7193 16.1689 13.7022C16.1764 13.6852 16.1879 13.6702 16.2026 13.6585L23.5816 9.40149C24.7287 8.73987 26.0383 8.40868 27.366 8.44665C28.6937 8.48462 29.9818 8.89058 31.0884 9.61725C32.1949 10.3439 33.0772 11.3622 33.6394 12.5586C34.2017 13.755 34.4225 15.0824 34.2767 16.3941L33.7965 16.5276ZM14.4844 22.7795L11.3995 21.0014C11.383 20.9931 11.3686 20.9809 11.3577 20.9659C11.3469 20.951 11.3399 20.9335 11.337 20.9152V12.3984C11.3382 11.0394 11.7271 9.70991 12.4587 8.56559C13.1903 7.42128 14.2342 6.50777 15.4659 5.93343C16.6977 5.35908 18.0667 5.14736 19.4111 5.32274C20.7556 5.49813 22.0223 6.05331 23.0635 6.92265C23.0026 6.95591 22.8957 7.01396 22.8262 7.05591L15.5273 11.2692C15.3454 11.3741 15.1944 11.5249 15.0892 11.7065C14.9839 11.8881 14.928 12.094 14.928 12.3034L14.4844 22.7795ZM16.1593 19.1581L20.1262 16.8635L24.0928 19.1581V23.7459L20.1262 26.0406L16.1593 23.7459V19.1581Z" fill="#0D0D0D"/></svg>`,
          
            // Thumbs-up / down for assistant message feedback chips.
            thumbsUp: `<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#7A7A7A" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11H4a1 1 0 0 1-1-1V11a1 1 0 0 1 1-1h3z"/><path d="M7 10l4-7c1.7 0 2.5 1 2.3 2.4L13 10h5.6a2 2 0 0 1 2 2.3l-1.2 6.4A2 2 0 0 1 17.4 21H7"/></svg>`,
            thumbsDown: `<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#7A7A7A" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V3h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-3z"/><path d="M17 14l-4 7c-1.7 0-2.5-1-2.3-2.4L11 14H5.4a2 2 0 0 1-2-2.3l1.2-6.4A2 2 0 0 1 6.6 3H17"/></svg>`,
          
            // iPhone status bar icons (right cluster).
            signal: `<svg width="17" height="11" viewBox="0 0 17 11" fill="#0D0D0D"><rect x="0" y="7" width="3" height="4" rx="0.7"/><rect x="4.5" y="5" width="3" height="6" rx="0.7"/><rect x="9" y="3" width="3" height="8" rx="0.7"/><rect x="13.5" y="0" width="3" height="11" rx="0.7"/></svg>`,
            wifi: `<svg width="17" height="12" viewBox="0 0 17 12" fill="#0D0D0D"><path d="M8.5 0C5.4 0 2.55 1.13 .35 3 a.75.75 0 0 0-.05 1.05l1 1.07a.74.74 0 0 0 1.05.05A10 10 0 0 1 8.5 2.5a10 10 0 0 1 6.15 2.67.74.74 0 0 0 1.05-.05l1-1.07A.75.75 0 0 0 16.65 3 C 14.45 1.13 11.6 0 8.5 0z"/><path d="M8.5 4a8 8 0 0 0-5.4 2.07.74.74 0 0 0-.04 1.05l1.04 1.13a.74.74 0 0 0 1.05.04A6 6 0 0 1 8.5 6.5a6 6 0 0 1 3.35 1.79.74.74 0 0 0 1.05-.04l1.04-1.13a.74.74 0 0 0-.04-1.05A8 8 0 0 0 8.5 4z"/><path d="M8.5 8a3.5 3.5 0 0 0-2.4.95.74.74 0 0 0-.05 1.05l1.85 2 a.75.75 0 0 0 1.1 0l1.85-2a.74.74 0 0 0-.05-1.05A3.5 3.5 0 0 0 8.5 8z"/></svg>`,
            battery: `<svg width="27" height="13" viewBox="0 0 27 13" fill="none"><rect x="0.5" y="0.5" width="22" height="12" rx="3.5" stroke="#0D0D0D" stroke-opacity="0.35"/><rect x="2" y="2" width="19" height="9" rx="2" fill="#0D0D0D"/><rect x="23.5" y="4" width="2" height="5" rx="1" fill="#0D0D0D" fill-opacity="0.35"/></svg>`,
          
            // Moon glyph that shows next to the time when Focus / Do-Not-Disturb is on.
            // The reference Bioma screenshot shows "08:18 ☾" — render that subtle moon.
            moonDND: `<svg width="13" height="13" viewBox="0 0 13 13" fill="#0D0D0D"><path d="M11.5 8.2a4.7 4.7 0 1 1-6.7-5.7 5 5 0 1 0 6.7 5.7z"/></svg>`,
          
            // Stop / square icon shown inside the send button while a response is streaming.
            stopSquare: `<svg width="18" height="18" viewBox="0 0 14 14" fill="#FFFFFF"><rect x="2" y="2" width="10" height="10" rx="1.5"/></svg>`,
          
            // -------- Keyboard glyphs (ported from create-apple-notes-mockup) --------
            kbdShift: `<svg width="32" height="32" viewBox="0 0 32 32" fill="none" stroke="#1c1c1e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M16 5 L5 16 H11 V26 H21 V16 H27 Z"/></svg>`,
            kbdBackspace: `<svg width="40" height="28" viewBox="0 0 40 28" fill="none" stroke="#1c1c1e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13 3 L3 14 L13 25 H35 V3 Z"/><path d="M19 9 L29 19 M29 9 L19 19"/></svg>`,
            kbdReturn: `<svg width="34" height="28" viewBox="0 0 34 28" fill="none" stroke="#1c1c1e" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M28 5 V14 H8 M14 10 L8 14 L14 18"/></svg>`,
            kbdGlobe: `<svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="#1c1c1e" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><path d="M2.5 12 H21.5 M12 2.5 V21.5 M5 5.5 Q12 10 19 5.5 M5 18.5 Q12 14 19 18.5 M12 2.5 C7 7 7 17 12 21.5 M12 2.5 C17 7 17 17 12 21.5"/></svg>`,
            kbdMic: `<svg width="28" height="36" viewBox="0 0 24 28" fill="#1c1c1e"><path d="M12 16a3.5 3.5 0 0 0 3.5-3.5V6a3.5 3.5 0 0 0-7 0v6.5A3.5 3.5 0 0 0 12 16z"/><path d="M19 12.5a1 1 0 0 0-2 0 5 5 0 0 1-10 0 1 1 0 0 0-2 0 7 7 0 0 0 6 6.93V22h-2.25a1 1 0 0 0 0 2h6.5a1 1 0 0 0 0-2H13v-2.57A7 7 0 0 0 19 12.5z"/></svg>`,
            kbdEmoji: `<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#1c1c1e" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9.5"/><circle cx="9" cy="10" r="1" fill="#1c1c1e" stroke="none"/><circle cx="15" cy="10" r="1" fill="#1c1c1e" stroke="none"/><path d="M8 15 Q12 18 16 15"/></svg>`,
          };
          
          module.exports = ICONS;
          
      • generate.js 16.3 KB
        #!/usr/bin/env node
        /**
         * generate.js — Convert a ChatGPT thread JSON into a single HTML page that
         * mimics the ChatGPT mobile (iOS) app in light mode.
         *
         * Usage:
         *   const { renderHTML } = require('./generate');
         *   const html = renderHTML(thread);
         *   node generate.js --thread examples/text-only.json --out /tmp/index.html
         */
        
        const fs = require('fs');
        const path = require('path');
        const ICONS = require('./templates/icons');
        
        function escapeHTML(s) {
          return String(s)
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#39;');
        }
        
        // Tiny markdown-ish renderer for assistant prose. Intentional scope:
        //   **bold**, *italic*, # H1, ## H2, ### H3, * / - list items,
        //   blank-line paragraph breaks, `---` horizontal rule, [[cite:Source]] chip,
        //   [[icon:💡]] inline-icon prefix.
        function renderMarkdown(src) {
          if (!src) return '';
          const lines = src.replace(/\r\n/g, '\n').split('\n');
          const out = [];
          let buf = [];
          let inList = false;
          let inOrdered = false;
        
          const flushParagraph = () => {
            if (!buf.length) return;
            // Format each line through inline-markdown first (escaping happens there),
            // then join with <br> so single-newline line breaks (e.g. inside a poem
            // stanza or under a bold "Section:" header) are preserved. Real ChatGPT
            // renders single newlines as soft breaks too.
            const joined = buf.map(l => formatInline(l)).join('<br>').replace(/^(<br>)+|(<br>)+$/g, '');
            if (joined) out.push(`<p>${joined}</p>`);
            buf = [];
          };
          const flushList = () => {
            if (inList) { out.push('</ul>'); inList = false; }
            if (inOrdered) { out.push('</ol>'); inOrdered = false; }
          };
        
          for (let raw of lines) {
            const line = raw.replace(/\s+$/, '');
            if (!line.trim()) { flushParagraph(); flushList(); continue; }
        
            // Horizontal rule
            if (/^---+\s*$/.test(line)) { flushParagraph(); flushList(); out.push('<hr>'); continue; }
        
            // Headings
            const h = /^(#{1,3})\s+(.*)$/.exec(line);
            if (h) {
              flushParagraph(); flushList();
              const level = h[1].length;
              out.push(`<h${level}>${formatInline(h[2])}</h${level}>`);
              continue;
            }
        
            // Bullet item
            const li = /^[\*\-]\s+(.*)$/.exec(line);
            if (li) {
              flushParagraph();
              if (inOrdered) { out.push('</ol>'); inOrdered = false; }
              if (!inList) { out.push('<ul>'); inList = true; }
              out.push(`<li>${formatInline(li[1])}</li>`);
              continue;
            }
        
            // Ordered item
            const oli = /^(\d+)\.\s+(.*)$/.exec(line);
            if (oli) {
              flushParagraph();
              if (inList) { out.push('</ul>'); inList = false; }
              if (!inOrdered) { out.push('<ol>'); inOrdered = true; }
              out.push(`<li>${formatInline(oli[2])}</li>`);
              continue;
            }
        
            // Bolded "Section title:" pattern → make it visually a section header
            const sec = /^\*\*(.+?)\*\*:?\s*$/.exec(line);
            if (sec) {
              flushParagraph(); flushList();
              out.push(`<p class="section-title">${formatInline(sec[1])}</p>`);
              continue;
            }
        
            buf.push(line);
          }
          flushParagraph(); flushList();
          return out.join('\n');
        }
        
        function formatInline(s) {
          let out = escapeHTML(s);
          // Inline icon prefix (kept un-escaped because emoji is a glyph, not markup).
          out = out.replace(/\[\[icon:(.+?)\]\]/g, (_, ico) => `<span class="icon-prefix">${ico}</span>`);
          // Cite chip
          out = out.replace(/\[\[cite:(.+?)\]\]/g, (_, c) => `<span class="cite">${c}</span>`);
          // Bold **text**
          out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
          // Italic *text* — must come AFTER bold so we don't eat ** markers
          out = out.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
          return out;
        }
        
        // ---------------------------------------------------------------------------
        // Header
        // ---------------------------------------------------------------------------
        
        function renderStatusBar(thread) {
          const sb = thread.statusBar || {};
          const time = sb.time || '9:41';
          const dnd = sb.dnd === true; // moon glyph next to time
          // Default: full cluster (signal + wifi + battery)
          return `
            <div class="status-bar">
              <div class="time">${escapeHTML(time)} ${dnd ? ICONS.moonDND : ''}</div>
              <div class="right-cluster">
                ${ICONS.signal}
                ${ICONS.wifi}
                ${ICONS.battery}
              </div>
            </div>
          `;
        }
        
        function renderHeader(thread) {
          const h = thread.header || {};
          const style = h.style || 'model-tag'; // model-tag | title-only
          const title = h.title || 'ChatGPT';
          const model = h.model || ''; // e.g. "5.1"
          const rightIcons = h.rightIcons || (style === 'title-only' ? ['edit'] : ['personPlus', 'dottedCircle']);
          // Optional alternate right-icon set, faded-in by the driver when the chat
          // transitions from "empty" to "active". Render both sets stacked when both
          // are provided — the driver toggles `data-active` between "primary"/"alt".
          const rightIconsAlt = h.rightIconsAlt || null;
        
          // Center cluster
          let center = '';
          if (style === 'title-only') {
            // "ChatGPT >"  hard-left actually but iOS centers — match reference.
            center = `<div class="center"><span>${escapeHTML(title)}</span><span class="chev-right">${ICONS.chevronRight}</span></div>`;
          } else if (style === 'plain-title') {
            center = `<div class="center"><span>${escapeHTML(title)}</span></div>`;
          } else {
            // model-tag default → "ChatGPT 5.1 v"
            center = `<div class="center"><span>${escapeHTML(title)}</span>${model ? `<span class="model-tag">${escapeHTML(model)}</span>` : ''}<span class="chev-down">${ICONS.chevronDown}</span></div>`;
          }
        
          const iconMap = {
            personPlus: ICONS.personPlus,
            dottedCircle: ICONS.dottedCircle,
            edit: ICONS.editPencil,
            more: ICONS.moreDots,
          };
          const renderCluster = (keys) => keys.map(k => iconMap[k] || '').join('');
          const rightHTML = rightIconsAlt
            ? `<div class="cluster primary" data-cluster="primary">${renderCluster(rightIcons)}</div>
               <div class="cluster alt" data-cluster="alt">${renderCluster(rightIconsAlt)}</div>`
            : renderCluster(rightIcons);
        
          const rightAttrs = rightIconsAlt ? ' data-active="primary"' : '';
        
          return `
            <div class="gpt-header ${style === 'title-only' ? 'title-center' : ''}">
              <div class="left">
                <div class="hamburger">${ICONS.hamburger}</div>
              </div>
              ${center}
              <div class="right"${rightAttrs}>${rightHTML}</div>
            </div>
          `;
        }
        
        // ---------------------------------------------------------------------------
        // Messages
        // ---------------------------------------------------------------------------
        
        function animAttrs(msg) {
          // In animation mode every row gets a stable id (assigned upstream) and may
          // start in a hidden state. data-pending="1" hides via display:none in CSS.
          const id = msg.id ? ` data-anim-id="${escapeHTML(msg.id)}"` : '';
          const pending = msg.popState === 'pending' ? ' data-pending="1"' : '';
          const animClass = msg.popState === 'pending' ? 'pop-pending'
                          : msg.popState === 'now' ? 'pop-now' : '';
          return { id, pending, animClass };
        }
        
        function renderUserText(msg) {
          const a = animAttrs(msg);
          return `<div class="row user ${a.animClass}"${a.id}${a.pending}><div class="bubble">${escapeHTML(msg.text || '').replace(/\n/g, '<br>')}</div></div>`;
        }
        
        function renderUserImage(msg) {
          const a = animAttrs(msg);
          const src = msg.src || '';
          const cls = msg.aspect === 'square' ? 'attachment square' : 'attachment';
          return `<div class="row user-image ${a.animClass}"${a.id}${a.pending}><div class="${cls}"><img src="${escapeHTML(src)}" alt=""></div></div>`;
        }
        
        // Wrap visible text nodes in <span class="word" data-stream="0">word</span>
        // without touching tags or already-wrapped content. Whitespace is preserved
        // outside the spans so word gaps are real spaces (CSS doesn't have to fake
        // them with padding).
        function wrapWordsForStreaming(html) {
          const out = [];
          let i = 0;
          while (i < html.length) {
            if (html[i] === '<') {
              // Pass tags through unchanged
              const end = html.indexOf('>', i);
              if (end === -1) { out.push(html.slice(i)); break; }
              out.push(html.slice(i, end + 1));
              i = end + 1;
              continue;
            }
            // Read a text run up to the next tag
            const nextTag = html.indexOf('<', i);
            const run = nextTag === -1 ? html.slice(i) : html.slice(i, nextTag);
            // Tokenize the run into words + whitespace
            run.split(/(\s+)/).forEach(tok => {
              if (!tok) return;
              if (/^\s+$/.test(tok)) out.push(tok);
              else out.push(`<span class="word" data-stream="0">${tok}</span>`);
            });
            i = nextTag === -1 ? html.length : nextTag;
          }
          return out.join('');
        }
        
        // Browser-rendered list markers (the bullet glyphs on `<li>`) can't be
        // hidden by the word-stream wrapper because they're CSS-painted, not text.
        // So when streaming, convert `<ul><li>X</li></ul>` to a div tree whose
        // items begin with "• " as literal text — that bullet then becomes a
        // streamable word and arrives with its line.
        function inlineListBulletsForStreaming(html) {
          return html
            .replace(/<ul[^>]*>/g, '<div class="md-list">')
            .replace(/<\/ul>/g, '</div>')
            .replace(/<ol[^>]*>/g, '<div class="md-list ordered">')
            .replace(/<\/ol>/g, '</div>')
            .replace(/<li>/g, '<div class="md-li">• ')
            .replace(/<\/li>/g, '</div>');
        }
        
        function renderAssistant(msg) {
          const a = animAttrs(msg);
          // Real ChatGPT renders the assistant title as plain bold text — no spiral
          // logo. (The logo only appears in the empty-state hero, not before each
          // message.)
          const titleRaw = msg.title
            ? `<div class="title-row"><span class="title-text">${escapeHTML(msg.title)}</span></div>`
            : '';
          let body = renderMarkdown(msg.text || '');
          let title = titleRaw;
          if (msg.stream === true) {
            body = inlineListBulletsForStreaming(body);
            body = wrapWordsForStreaming(body);
            if (title) title = wrapWordsForStreaming(title);
          }
          const feedback = msg.feedback === false ? '' :
            `<div class="feedback">${ICONS.thumbsUp}${ICONS.thumbsDown}</div>`;
          const streamCls = msg.stream === true ? ' streaming-body' : '';
          return `
            <div class="row assistant ${a.animClass}"${a.id}${a.pending}>
              ${title}
              <div class="assistant-body${streamCls}">${body}</div>
              ${feedback}
            </div>
          `;
        }
        
        function renderLoadingDot(msg) {
          const a = animAttrs(msg);
          return `<div class="row loading-dot ${a.animClass}"${a.id}${a.pending}><div class="dot"></div></div>`;
        }
        
        function renderEmptyState() {
          return `<div class="empty-state"><div class="big-logo">${ICONS.openaiSpiral}</div></div>`;
        }
        
        // ---------------------------------------------------------------------------
        // Composer
        // ---------------------------------------------------------------------------
        
        function renderComposer(thread) {
          const c = thread.composer || {};
          const placeholder = c.placeholder || 'Ask anything';
          const hasText = c.text != null && c.text !== '';
          const streaming = c.streaming === true;
          const showCaret = c.cursor !== false && hasText;
          const sendCls = streaming ? 'streaming' : (hasText ? 'active' : '');
          const sendIcon = streaming ? ICONS.stopSquare : ICONS.sendArrow;
        
          // Optional Apps-SDK GPT chip
          if (c.chip) {
            const name = c.chip.name || 'GPT';
            return `
              <div class="composer-wrap">
                <div class="composer with-chip">
                  <div class="gpt-chip">
                    <span>${escapeHTML(name)}</span>
                    <span class="x">×</span>
                  </div>
                  <div class="input-row">
                    <div class="plus-btn">${ICONS.plus}</div>
                    <div class="input">${hasText ? `<span class="composer-text">${escapeHTML(c.text)}</span>` : `<span class="placeholder">${escapeHTML(placeholder)}</span>`}${showCaret ? '<span class="caret"></span>' : ''}</div>
                    <div class="mic-btn">${ICONS.mic}</div>
                    <div class="send-btn ${sendCls}">${sendIcon}</div>
                  </div>
                </div>
              </div>
            `;
          }
        
          return `
            <div class="composer-wrap">
              <div class="composer">
                <div class="plus-btn">${ICONS.plus}</div>
                <div class="input">${hasText ? `<span class="composer-text">${escapeHTML(c.text)}</span>` : `<span class="placeholder">${escapeHTML(placeholder)}</span>`}${showCaret ? '<span class="caret"></span>' : ''}</div>
                <div class="mic-btn">${ICONS.mic}</div>
                <div class="send-btn ${sendCls}">${sendIcon}</div>
              </div>
            </div>
          `;
        }
        
        // ---------------------------------------------------------------------------
        // Main render
        // ---------------------------------------------------------------------------
        
        function loadCSS() {
          return fs.readFileSync(path.join(__dirname, 'templates', 'chat.css'), 'utf-8');
        }
        
        function renderMessages(messages) {
          return (messages || []).map(m => {
            if (m.type === 'user-text') return renderUserText(m);
            if (m.type === 'user-image') return renderUserImage(m);
            if (m.type === 'assistant') return renderAssistant(m);
            if (m.type === 'loading-dot') return renderLoadingDot(m);
            return '';
          }).join('\n');
        }
        
        function renderHTML(thread) {
          const css = loadCSS();
          const messages = thread.messages || [];
          const hasMessages = messages.length > 0;
          const inner = hasMessages
            ? `<div class="conversation">${renderMessages(messages)}</div>`
            : renderEmptyState();
        
          // Inline keyboard (no external atom). Same structure as the apple-notes
          // mockup's .kbd block, scaled to fit a 750-wide stage. Driver toggles
          // data-state and the stage's data-keyboard-shown to swap layouts.
          const kbHTML = thread.keyboard ? renderKeyboard(thread.keyboard) : '';
          const stageAttrs = thread.keyboard && thread.keyboard.state === 'shown'
            ? ' data-keyboard-shown="1"' : '';
        
          return `<!DOCTYPE html>
        <html><head><meta charset="utf-8"><style>${css}</style></head>
        <body>
          <div class="stage"${stageAttrs}>
            ${renderStatusBar(thread)}
            ${renderHeader(thread)}
            ${inner}
            ${renderComposer(thread)}
            ${kbHTML}
          </div>
        </body></html>`;
        }
        
        // Inline iOS QWERTY keyboard — modeled on create-apple-notes-mockup's .kbd
        // block. The chatgpt variant has no formatting toolbar (apple-notes' Aa /
        // checklist / etc.) because the composer pill sits above instead.
        function renderKeyboard(kb) {
          if (!kb) return '';
          const id = kb.id || 'kb';
          const state = kb.state || 'hidden';
          const upper = kb.shift === 'upper';
          const ltr = c => upper ? c.toUpperCase() : c;
          const r1 = (kb.letters_row1 || 'qwertyuiop').split('');
          const r2 = (kb.letters_row2 || 'asdfghjkl').split('');
          const r3 = (kb.letters_row3 || 'zxcvbnm').split('');
          const sug = kb.suggestions || ['I', 'The', "I'm"];
          return `
            <div class="kbd" data-anim-id="${escapeHTML(id)}" data-state="${escapeHTML(state)}">
              <div class="kbd-suggestions">
                ${sug.map(s => `<div class="kbd-suggestion">${escapeHTML(s)}</div>`).join('')}
              </div>
              <div class="kbd-row">
                ${r1.map(c => `<div class="kbd-key">${escapeHTML(ltr(c))}</div>`).join('')}
              </div>
              <div class="kbd-row kbd-row--abc">
                ${r2.map(c => `<div class="kbd-key">${escapeHTML(ltr(c))}</div>`).join('')}
              </div>
              <div class="kbd-row kbd-row--zxc">
                <div class="kbd-key kbd-key--wide">${ICONS.kbdShift || '⇧'}</div>
                ${r3.map(c => `<div class="kbd-key">${escapeHTML(ltr(c))}</div>`).join('')}
                <div class="kbd-key kbd-key--wide">${ICONS.kbdBackspace || '⌫'}</div>
              </div>
              <div class="kbd-bottom">
                <div class="kbd-key kbd-key--num">123</div>
                <div class="kbd-key kbd-key--emoji">${ICONS.kbdEmoji || '☻'}</div>
                <div class="kbd-key kbd-key--space">space</div>
                <div class="kbd-key kbd-key--return">${ICONS.kbdReturn || '⏎'}</div>
              </div>
              <div class="kbd-footer">
                ${ICONS.kbdGlobe || ''}
                ${ICONS.kbdMic || ''}
              </div>
            </div>
          `;
        }
        
        function parseArgs(argv) {
          const args = {};
          for (let i = 2; i < argv.length; i++) {
            if (argv[i] === '--thread') args.thread = argv[++i];
            else if (argv[i] === '--out') args.out = argv[++i];
          }
          return args;
        }
        
        function main() {
          const args = parseArgs(process.argv);
          if (!args.thread || !args.out) {
            console.error('Usage: node generate.js --thread path.json --out path.html');
            process.exit(1);
          }
          const thread = JSON.parse(fs.readFileSync(args.thread, 'utf-8'));
          const html = renderHTML(thread);
          fs.mkdirSync(path.dirname(args.out), { recursive: true });
          fs.writeFileSync(args.out, html);
          console.log(`Wrote ${args.out}`);
        }
        
        if (require.main === module) main();
        
        module.exports = { renderHTML };
        
    • config.example.json 3.1 KB
      {
        "_comment": "Example config for render-chatgpt-chat. The recipe (DB) binds the brand's own thread + timeline + end-card here at remix time. The punchline is the STREAMED ASSISTANT ANSWER — the brand surfacing as the natural response to 'I just asked ChatGPT…'. thread + timeline drive record-chat.js; end_card + brand_name drive render-end-card.js. Keyframe rules: keyboard is up while the user types and slides down on the SAME t as send-tap; header cluster swaps in that same beat; ONE gray loading dot (never three); assistant message has stream:true so it reveals word-by-word.",
        "brand_name": "ExampleBrand",
        "width": 750,
        "height": 1624,
        "sfx": true,
        "thread": {
          "statusBar": { "time": "9:41" },
          "header": {
            "style": "plain-title",
            "title": "ChatGPT",
            "rightIcons": ["personPlus", "dottedCircle"],
            "rightIconsAlt": ["edit", "more"]
          },
          "keyboard": { "layout": "qwerty-lower", "suggestions": ["I", "The", "I'm"], "state": "hidden" },
          "messages": [
            { "type": "user-text", "id": "msg-user-1", "text": "what's the easiest way to plan a week of meals?", "popState": "pending" },
            { "type": "loading-dot", "id": "dot-1", "popState": "pending" },
            {
              "type": "assistant",
              "id": "msg-assistant-1",
              "stream": true,
              "popState": "pending",
              "feedback": false,
              "text": "The simplest approach most people land on:\n\n* Pick 3-4 dinners you actually like\n* Batch one protein + one grain on Sunday\n* Let an app build the grocery list for you\n\nExampleBrand does all three in one tap — you pick the meals, it plans the week and orders the ingredients."
            }
          ],
          "composer": { "placeholder": "Ask ChatGPT" }
        },
        "timeline": [
          { "t": 0.0,  "kind": "keyboard-show" },
          { "t": 0.5,  "kind": "composer-type", "text": "what's the easiest way to plan a week of meals?", "dur_sec": 2.6 },
          { "t": 3.3,  "kind": "send-tap" },
          { "t": 3.3,  "kind": "pop", "target": "msg-user-1" },
          { "t": 3.3,  "kind": "composer-clear" },
          { "t": 3.3,  "kind": "header-swap", "value": "alt" },
          { "t": 3.3,  "kind": "keyboard-hide" },
          { "t": 3.3,  "kind": "send-state", "value": "streaming" },
          { "t": 3.65, "kind": "loading-dot-show", "target": "dot-1" },
          { "t": 4.2,  "kind": "loading-dot-hide", "target": "dot-1" },
          { "t": 4.2,  "kind": "pop", "target": "msg-assistant-1" },
          { "t": 4.25, "kind": "stream-words", "target": "msg-assistant-1", "dur_sec": 6.5, "wps": 7 },
          { "t": 4.6,  "kind": "scroll-to", "target": "msg-assistant-1", "dur_ms": 300 },
          { "t": 11.0, "kind": "send-state", "value": "active" }
        ],
        "end_card": {
          "bg": "#ffffff",
          "fg": "#111111",
          "cta_bg": "#111111",
          "cta_fg": "#ffffff",
          "star_color": "#f5b301",
          "wordmark_text": "EXAMPLEBRAND",
          "logo_svg": "",
          "stars": 5,
          "proof_text": "Meal planning in one tap",
          "trust_trio": [
            { "label": "Pick your\nmeals", "icon": "check" },
            { "label": "Auto grocery\nlist", "icon": "gift" },
            { "label": "Ready in\nminutes", "icon": "bolt" }
          ],
          "cta_text": "Plan my week",
          "dwell_sec": 2.5
        }
      }
      
    • end-card.template.html 2.5 KB · in bundle
    • package.json 320 B
      {
        "name": "render-chatgpt-chat",
        "private": true,
        "version": "0.1.0",
        "description": "Free renderer for the chatgpt-chat video ad format (Playwright record + ffmpeg stitch). Run `npm install` here so record-chat.js / render-end-card.js resolve Playwright.",
        "dependencies": {
          "playwright": "^1.40.0"
        }
      }
      
    • record-chat.js 14.5 KB
      #!/usr/bin/env node
      /**
       * record-chat.js — one-shot Playwright recording of a ChatGPT chat-reveal
       * animation, as a single continuous MP4. The ChatGPT sibling of
       * render-imessage-chat's record-chat.js.
       *
       * Renders the bundled create-chatgpt-mockup HTML once with every message
       * pre-pending, mounts the iOS keyboard, then walks a timeline on
       * requestAnimationFrame INSIDE the page (user types in the composer → tap send
       * → keyboard slides down + header cluster swaps in one beat → one gray loading
       * dot → assistant answer streams in word-by-word). The whole thing is captured
       * as ONE Playwright recording — never scene-by-scene (every reload flickers).
       *
       * Portable: everything it needs (the chatgpt-mockup generator + templates) is
       * bundled under ./mockup, so it runs unchanged from a fetched
       * /tmp/gooseworks-scripts/render-chatgpt-chat/scripts/ checkout. No repo-relative
       * or /Users paths.
       *
       * Output: <out-dir>/master-chat.mp4  +  <out-dir>/master-chat.sfx.json
       *
       * Usage:
       *   npm install                       # once, in this scripts/ folder (Playwright)
       *   node record-chat.js --config config.json [--out-dir .]
       *
       * config.json shape (see config.example.json):
       *   {
       *     "thread":   { ...chatgpt thread JSON },   // or "thread_path"
       *     "timeline": [ ...timeline events ],       // or "timeline_path"
       *     "sfx":   true,                            // derive subliminal SFX cues (default true)
       *     "width": 750, "height": 1624              // output geometry (defaults — modern iPhone Pro ~9:19.5)
       *   }
       * Relative *_path values resolve against the config file's directory.
       */
      
      const fs = require('fs');
      const path = require('path');
      const { execSync } = require('child_process');
      const { chromium } = require('playwright');
      const { renderHTML } = require('./mockup/generate.js');
      
      // ── args ────────────────────────────────────────────────────────────────────
      function parseArgs(argv) {
        const a = { config: 'config.json', outDir: '.' };
        for (let i = 2; i < argv.length; i++) {
          if (argv[i] === '--config') a.config = argv[++i];
          else if (argv[i] === '--out-dir') a.outDir = argv[++i];
        }
        return a;
      }
      
      // ── deterministic SFX cue list (subliminal — see SKILL.md "SFX are felt") ─────
      // One key-tap per word while typing, send-tap on the tap, stream-tick every 12
      // words while streaming, response-done after the last word. Levels are applied
      // per-name in stitch.sh; there is NEVER a cue on the loading dot.
      function deriveSFX(timeline) {
        const cues = [];
        for (const ev of timeline) {
          if (ev.kind === 'send-tap') {
            cues.push({ t: +ev.t.toFixed(3), name: 'send-tap' });
          } else if (ev.kind === 'composer-type') {
            const words = String(ev.text || '').split(/\s+/).filter(Boolean);
            const dur = ev.dur_sec || 1;
            const perWord = dur / Math.max(words.length, 1);
            for (let i = 0; i < words.length; i++) {
              cues.push({ t: +(ev.t + i * perWord).toFixed(3), name: 'key-tap' });
            }
          } else if (ev.kind === 'stream-words') {
            const wps = ev.wps || 7;
            const dur = ev.dur_sec || 6;
            const totalWords = Math.round(wps * dur);
            const perWord = 1 / wps;
            for (let i = 12; i < totalWords; i += 12) {
              cues.push({ t: +(ev.t + i * perWord).toFixed(3), name: 'stream-tick' });
            }
            cues.push({ t: +(ev.t + dur + 0.05).toFixed(3), name: 'response-done' });
          }
        }
        cues.sort((a, b) => a.t - b.t);
        return cues;
      }
      
      // ── the driver — runs INSIDE the page on requestAnimationFrame ────────────────
      // Serialized into the page; keep it self-contained (no require()s). The
      // timeline is injected as window.__TIMELINE__.
      function driverFnSource() {
        return function runDriver() {
          const TL = window.__TIMELINE__ || [];
          const t0 = performance.now();
          const stage = document.querySelector('.stage');
          const composerInput = document.querySelector('.composer .input');
          const sendBtn = document.querySelector('.composer .send-btn');
          const header = document.querySelector('.gpt-header .right');
          const kb = document.querySelector('.kbd');
      
          function setComposerText(text) {
            if (!composerInput) return;
            const ct = composerInput.querySelector('.composer-text');
            const ph = composerInput.querySelector('.placeholder');
            if (ph) ph.style.display = text ? 'none' : '';
            if (ct) {
              ct.textContent = text;
              ct.style.display = text ? '' : 'none';
            } else if (text) {
              const span = document.createElement('span');
              span.className = 'composer-text';
              span.textContent = text;
              composerInput.insertBefore(span, composerInput.firstChild);
            }
            let caret = composerInput.querySelector('.caret');
            if (text && !caret) {
              caret = document.createElement('span');
              caret.className = 'caret';
              composerInput.appendChild(caret);
            } else if (!text && caret) {
              caret.remove();
            }
            if (sendBtn) sendBtn.classList.toggle('active', !!text);
          }
      
          function setSendState(state) {
            if (!sendBtn) return;
            sendBtn.classList.remove('active', 'streaming');
            if (state === 'active' || state === 'streaming') sendBtn.classList.add(state);
            const arrow = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19 V5 M5 12 L12 5 L19 12"/></svg>`;
            const stop = `<svg width="18" height="18" viewBox="0 0 14 14" fill="#FFFFFF"><rect x="2" y="2" width="10" height="10" rx="1.5"/></svg>`;
            sendBtn.innerHTML = state === 'streaming' ? stop : arrow;
          }
      
          function popRow(id) {
            const row = document.querySelector(`[data-anim-id="${id}"]`);
            if (!row) return;
            row.removeAttribute('data-pending');
            row.classList.add('pop-now');
          }
          function hideRow(id) {
            const row = document.querySelector(`[data-anim-id="${id}"]`);
            if (!row) return;
            row.setAttribute('data-pending', '1');
            row.classList.remove('pop-now');
          }
      
          function streamWords(id, dur_sec, wps, startedAt) {
            const row = document.querySelector(`[data-anim-id="${id}"]`);
            if (!row) return null;
            const words = Array.from(row.querySelectorAll('.word[data-stream="0"]'));
            const total = words.length;
            const totalDurMs = dur_sec * 1000;
            const revealAt = words.map((_, i) => (i / total) * totalDurMs);
            return { row, words, revealAt, startedAt, total, done: 0 };
          }
      
          function smoothScrollTo(targetY, dur_ms) {
            const start = window.scrollY;
            const delta = targetY - start;
            const t0scroll = performance.now();
            function step(now) {
              const p = Math.min(1, (now - t0scroll) / dur_ms);
              const ease = 0.5 - 0.5 * Math.cos(Math.PI * p);
              window.scrollTo(0, start + delta * ease);
              if (p < 1) requestAnimationFrame(step);
            }
            requestAnimationFrame(step);
          }
      
          const typingJobs = [];
          function startTyping(text, dur_sec, startedAt) {
            typingJobs.push({ text, dur_sec, startedAt, lastIdx: 0 });
          }
      
          const streamJobs = [];
          let cursor = 0;
      
          function tick(now) {
            const t = (now - t0) / 1000;
      
            while (cursor < TL.length && TL[cursor].t <= t) {
              const ev = TL[cursor++];
              switch (ev.kind) {
                case 'composer-type':
                  startTyping(ev.text, ev.dur_sec || 2, now);
                  break;
                case 'composer-clear':
                  setComposerText('');
                  break;
                case 'send-tap':
                  if (sendBtn) {
                    sendBtn.style.transformOrigin = 'center';
                    sendBtn.animate(
                      [{ transform: 'scale(0.86)' }, { transform: 'scale(1.0)' }],
                      { duration: 140, easing: 'cubic-bezier(0.2,0.7,0.2,1)' }
                    );
                  }
                  break;
                case 'pop':
                  popRow(ev.target);
                  break;
                case 'header-swap':
                  if (header) header.setAttribute('data-active', ev.value || 'alt');
                  break;
                case 'keyboard-show':
                  if (kb) kb.setAttribute('data-state', 'shown');
                  if (stage) stage.setAttribute('data-keyboard-shown', '1');
                  break;
                case 'keyboard-hide':
                  if (kb) kb.setAttribute('data-state', 'hidden');
                  if (stage) stage.removeAttribute('data-keyboard-shown');
                  break;
                case 'loading-dot-show':
                  popRow(ev.target);
                  break;
                case 'loading-dot-hide':
                  hideRow(ev.target);
                  break;
                case 'send-state':
                  setSendState(ev.value);
                  break;
                case 'stream-words': {
                  const j = streamWords(ev.target, ev.dur_sec || 6, ev.wps || 7, now);
                  if (j) streamJobs.push(j);
                  break;
                }
                case 'scroll-to': {
                  const row = document.querySelector(`[data-anim-id="${ev.target}"]`);
                  if (row) {
                    const rect = row.getBoundingClientRect();
                    const bottomMargin = 200;
                    const targetY = window.scrollY + rect.bottom - (window.innerHeight - bottomMargin);
                    smoothScrollTo(Math.max(0, targetY), ev.dur_ms || 250);
                  }
                  break;
                }
              }
            }
      
            for (const job of typingJobs) {
              const elapsed = (now - job.startedAt) / 1000;
              const targetIdx = Math.min(job.text.length, Math.floor((elapsed / job.dur_sec) * job.text.length));
              if (targetIdx !== job.lastIdx) {
                setComposerText(job.text.slice(0, targetIdx));
                job.lastIdx = targetIdx;
              }
            }
      
            for (const job of streamJobs) {
              const elapsed = now - job.startedAt;
              while (job.done < job.total && job.revealAt[job.done] <= elapsed) {
                job.words[job.done].setAttribute('data-stream', '1');
                job.done++;
              }
              if (job.done === Math.floor(job.total * 0.5) && !job.midScroll) {
                job.midScroll = true;
                const rect = job.row.getBoundingClientRect();
                const targetY = window.scrollY + rect.bottom - (window.innerHeight - 200);
                smoothScrollTo(Math.max(0, targetY), 350);
              }
            }
      
            const lastT = TL.length ? TL[TL.length - 1].t : 0;
            if (t > lastT + 1.5) {
              window.__DONE__ = true;
              return;
            }
            requestAnimationFrame(tick);
          }
      
          requestAnimationFrame(tick);
        };
      }
      
      // ── main ──────────────────────────────────────────────────────────────────────
      const styleOverride = `
        /* Soft fade on assistant pop so it doesn't snap when streaming begins. */
        .row.assistant.pop-now { animation-duration: 280ms; }
      `;
      
      async function main() {
        const args = parseArgs(process.argv);
        const configPath = path.resolve(args.config);
        const cfgDir = path.dirname(configPath);
        const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
      
        const resolveMaybe = (inline, rel) => {
          if (inline != null) return inline;
          if (rel) return JSON.parse(fs.readFileSync(path.resolve(cfgDir, rel), 'utf-8'));
          return null;
        };
        const thread = resolveMaybe(cfg.thread, cfg.thread_path);
        const timeline = resolveMaybe(cfg.timeline, cfg.timeline_path);
        if (!thread || !Array.isArray(timeline)) {
          console.error('config must supply `thread` (+ `timeline`) inline or via *_path');
          process.exit(1);
        }
      
        // Force every non-'now' message into 'pending' so the page renders hidden.
        for (const m of (thread.messages || [])) {
          if (!m.popState) m.popState = 'pending';
        }
        // Keyboard defaults to hidden if present (the timeline slides it up).
        if (thread.keyboard && !thread.keyboard.state) thread.keyboard.state = 'hidden';
      
        const VIEWPORT = { width: cfg.width || 750, height: cfg.height || 1624 };
        const DEVICE_SCALE = 2;
        const FPS = 30;
      
        const outDir = path.resolve(args.outDir);
        fs.mkdirSync(outDir, { recursive: true });
        const OUT_VIDEO = path.join(outDir, 'master-chat.mp4');
        const OUT_SFX = path.join(outDir, 'master-chat.sfx.json');
        const OUT_PAGE = path.join(outDir, 'master-chat.page.html');
      
        // 1) SFX cue list. Default: derive the subliminal cues from the timeline.
        //    Set `"sfx": false` in config to ship the chat silent.
        const cues = cfg.sfx === false ? [] : deriveSFX(timeline);
        fs.writeFileSync(OUT_SFX, JSON.stringify({ cues }, null, 2));
        console.log(`SFX cues  → ${OUT_SFX} (${cues.length} cues${cfg.sfx === false ? ' — silent' : ''})`);
      
        // 2) Render the page HTML once, inject the timeline + driver.
        let html = renderHTML(thread);
        const lastT = timeline.length ? timeline[timeline.length - 1].t : 0;
        const totalDurMs = Math.round((lastT + 1.6) * 1000);
        const driverFn = `(${driverFnSource().toString()})();`;
        html = html.replace(
          '</head>',
          `<style>${styleOverride}</style>
      <script>window.__TIMELINE__ = ${JSON.stringify(timeline)};
      window.__DONE__ = false;
      window.__START_DRIVER__ = function () { ${driverFn} };
      </script></head>`
        );
        fs.writeFileSync(OUT_PAGE, html);
        console.log(`Page HTML → ${OUT_PAGE}`);
      
        // 3) Boot Playwright with screen recording enabled.
        const browser = await chromium.launch({ headless: true });
        const ctx = await browser.newContext({
          viewport: VIEWPORT,
          deviceScaleFactor: DEVICE_SCALE,
          recordVideo: { dir: outDir, size: { width: VIEWPORT.width, height: VIEWPORT.height } },
        });
        const page = await ctx.newPage();
        await page.goto(`file://${OUT_PAGE}`, { waitUntil: 'load' });
        await page.waitForTimeout(300);
      
        // 4) Boot the driver from the host side (the load event fired before goto resolved).
        await page.evaluate(() => window.__START_DRIVER__ && window.__START_DRIVER__());
      
        // 5) Wait for the driver to flag done, then flush the recording.
        await page.waitForFunction(() => window.__DONE__ === true, { timeout: totalDurMs + 5000, polling: 100 });
        const video = page.video();
        await page.close();
        await ctx.close();
        await browser.close();
        if (!video) { console.error('No video recorded'); process.exit(1); }
        const raw = await video.path();
      
        // 6) Transcode Playwright's webm → h.264 mp4 at 30fps, silent.
        execSync(
          `ffmpeg -y -i "${raw}" -r ${FPS} -c:v libx264 -pix_fmt yuv420p -movflags +faststart -an "${OUT_VIDEO}"`,
          { stdio: 'inherit' }
        );
        fs.unlinkSync(raw);
        console.log(`\n✓ ${OUT_VIDEO}`);
      }
      
      main().catch(err => {
        console.error('Record failed:', err.message);
        console.error(err.stack);
        process.exit(1);
      });
      
    • render-end-card.js 6.2 KB
      #!/usr/bin/env node
      /**
       * render-end-card.js — render the data-driven end card to a PNG + a still MP4.
       *
       * Reads the recipe's `end_card` config (from config.json) and fills
       * end-card.template.html, then Playwright-screenshots it and makes a short MP4.
       * This is the "make the end card better" fix (QA GOOSE-2481): a designed card —
       * wordmark + ⭐ proof row + trust trio + CTA pill — not a bare logo.
       *
       * Output: <out-dir>/end-card.png  +  <out-dir>/scene-end-endcard.mp4
       *
       * Usage:
       *   node render-end-card.js --config config.json [--out-dir .]
       *
       * end_card config (see config.example.json):
       *   {
       *     "bg": "#fbf4e8", "fg": "#1e5751", "cta_bg": "#9fe778", "cta_fg": "#1e5751",
       *     "star_color": "#ffd15a",
       *     "logo_svg": "<svg …>…</svg>",   // preferred; else "wordmark_text": "BRAND"
       *     "stars": 5,                      // 0 hides the proof row
       *     "proof_text": "69,000 families",
       *     "trust_trio": [ {"label":"Easy to\npersonalize","icon":"pencil"}, … ],
       *     "cta_text": "Personalize his book",
       *     "dwell_sec": 2.5
       *   }
       * Relative paths (logo_svg_path) resolve against the config's directory.
       */
      
      const fs = require('fs');
      const path = require('path');
      const { execSync } = require('child_process');
      const { chromium } = require('playwright');
      
      function parseArgs(argv) {
        const a = { config: 'config.json', outDir: '.' };
        for (let i = 2; i < argv.length; i++) {
          if (argv[i] === '--config') a.config = argv[++i];
          else if (argv[i] === '--out-dir') a.outDir = argv[++i];
        }
        return a;
      }
      
      function esc(s) {
        return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
      }
      
      // A small default icon set (filled paths, currentColor via `fill` on the badge).
      const ICONS = {
        pencil: '<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>',
        heart: '<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>',
        palette: '<path d="M12 2C6.49 2 2 6.49 2 12s4.49 10 10 10c1.38 0 2.5-1.12 2.5-2.5 0-.61-.23-1.2-.64-1.67-.08-.1-.13-.21-.13-.33 0-.28.22-.5.5-.5H16c3.31 0 6-2.69 6-6 0-4.96-4.49-9-10-9zm-5.5 9c-.83 0-1.5-.67-1.5-1.5S5.67 8 6.5 8 8 8.67 8 9.5 7.33 11 6.5 11zm3-4C8.67 7 8 6.33 8 5.5S8.67 4 9.5 4s1.5.67 1.5 1.5S10.33 7 9.5 7zm5 0c-.83 0-1.5-.67-1.5-1.5S13.67 4 14.5 4s1.5.67 1.5 1.5S15.33 7 14.5 7zm3 4c-.83 0-1.5-.67-1.5-1.5S16.67 8 17.5 8s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"/>',
        star: '<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/>',
        check: '<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>',
        gift: '<path d="M20 6h-2.18c.11-.31.18-.65.18-1a3 3 0 0 0-5.5-1.65l-.5.67-.5-.68A3 3 0 0 0 6 4c0 .35.07.69.18 1H4a2 2 0 0 0-2 2v2h20V7a2 2 0 0 0-2-1zM4 11v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8H4z"/>',
        bolt: '<path d="M7 2v11h3v9l7-12h-4l4-8z"/>',
        shield: '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/>',
      };
      
      function iconSvg(name) {
        const p = ICONS[name] || ICONS.check;
        return `<svg viewBox="0 0 24 24">${p}</svg>`;
      }
      
      function main() {
        const args = parseArgs(process.argv);
        const configPath = path.resolve(args.config);
        const cfgDir = path.dirname(configPath);
        const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
        const ec = cfg.end_card || {};
      
        const tpl = fs.readFileSync(path.join(__dirname, 'end-card.template.html'), 'utf-8');
      
        // Wordmark: prefer an inline SVG (or a path to one), else a bold text fallback.
        let logoSvg = ec.logo_svg || '';
        if (!logoSvg && ec.logo_svg_path) logoSvg = fs.readFileSync(path.resolve(cfgDir, ec.logo_svg_path), 'utf-8');
        const wordmark = logoSvg
          ? logoSvg
          : `<div class="text">${esc(ec.wordmark_text || cfg.brand_name || 'BRAND')}</div>`;
      
        // Proof row (⭐ + text) — omit entirely if stars is 0/absent.
        const starCount = ec.stars == null ? 5 : ec.stars;
        const proof = (starCount > 0)
          ? `<div class="proof-row"><div class="stars">${'★'.repeat(starCount)}</div>` +
            (ec.proof_text ? `<div class="families">${esc(ec.proof_text)}</div>` : '') + `</div>`
          : '';
      
        // Trust trio — up to a handful of {label, icon} items.
        const trio = Array.isArray(ec.trust_trio) && ec.trust_trio.length
          ? `<div class="trust-trio">` + ec.trust_trio.map(it =>
              `<div class="item"><div class="badge">${iconSvg(it.icon)}</div>` +
              `<div class="label">${esc(it.label || '').replace(/\n/g, '<br>')}</div></div>`
            ).join('') + `</div>`
          : '';
      
        const html = tpl
          .replace('{{BG}}', ec.bg || '#fbf4e8')
          .replace('{{FG}}', ec.fg || '#1e5751')
          .replace('{{CTA_BG}}', ec.cta_bg || '#9fe778')
          .replace('{{CTA_FG}}', ec.cta_fg || '#1e5751')
          .replace('{{STAR}}', ec.star_color || '#ffd15a')
          .replace('{{WORDMARK}}', wordmark)
          .replace('{{PROOF}}', proof)
          .replace('{{TRIO}}', trio)
          .replace('{{CTA}}', esc(ec.cta_text || 'Learn more'));
      
        const outDir = path.resolve(args.outDir);
        fs.mkdirSync(outDir, { recursive: true });
        const htmlPath = path.join(outDir, 'end-card.html');
        const outPng = path.join(outDir, 'end-card.png');
        const outMp4 = path.join(outDir, 'scene-end-endcard.mp4');
        const dwell = ec.dwell_sec || 2.5;
        fs.writeFileSync(htmlPath, html);
      
        (async () => {
          const browser = await chromium.launch();
          const ctx = await browser.newContext({ viewport: { width: 1080, height: 1920 }, deviceScaleFactor: 1 });
          const page = await ctx.newPage();
          await page.goto('file://' + htmlPath, { waitUntil: 'load' });
          await page.waitForFunction(() => document.body.dataset.ready === 'true', { timeout: 5000 });
          await page.waitForTimeout(400);
          await page.screenshot({ path: outPng });
          await browser.close();
          execSync(
            `ffmpeg -y -loop 1 -i "${outPng}" -t ${dwell} -r 30 ` +
            `-vf "scale=1080:1920,format=yuv420p" -c:v libx264 -pix_fmt yuv420p -movflags +faststart "${outMp4}"`,
            { stdio: 'pipe' }
          );
          console.log(`png  → ${path.relative(process.cwd(), outPng)}`);
          console.log(`mp4  → ${path.relative(process.cwd(), outMp4)} (${dwell}s)`);
        })().catch(e => { console.error(e); process.exit(1); });
      }
      
      main();
      
    • stitch.sh 6.6 KB
      #!/usr/bin/env bash
      # stitch.sh — assemble the final ChatGPT chat ad:
      #   chat clip ─(300ms crossfade)→ end card, with a deterministic SUBLIMINAL
      #   ChatGPT SFX layer (key-tap / send-tap / stream-tick / response-done from
      #   master-chat.sfx.json) + an OPTIONAL ducked music bed, then an OPTIONAL 1:1
      #   variant. FREE assembly (ffmpeg only). Sibling of render-imessage-chat/stitch.sh.
      #
      # The chat records at the ChatGPT-native ~9:19.5 (default 750×1624). The generic
      # end card renders at 1080×1920 (9:16). To crossfade cleanly WITHOUT ever
      # stretching the chat (hard rule), the end card is scaled-to-fit the chat's
      # dimensions and PADDED (its own bg color) — the card content is centered so the
      # pad is seamless. Pass --pad-color <#hex> = the end_card.bg (default #ffffff,
      # ChatGPT light mode).
      #
      # ChatGPT SFX are subliminal by design (ChatGPT has no native chime). Per-name
      # levels: key-tap -28dB, send-tap -20dB, stream-tick -32dB, response-done -22dB.
      # NOTE: the bundled wavs are freshly SYNTHESIZED stand-ins — the originals were
      # lost from Git LFS (404). Swap in real wavs in assets/sfx/ for tuned SFX.
      #
      # Usage:
      #   stitch.sh --chat <master-chat.mp4> --end <scene-end-endcard.mp4> \
      #             --sfx <master-chat.sfx.json> --out <master-final.mp4> \
      #             [--music <music-bed.mp3>] [--sfx-dir <dir>] \
      #             [--pad-color <#hex>] [--also-1x1]
      set -euo pipefail
      
      HERE="$(cd "$(dirname "$0")" && pwd)"
      SFX_DIR="$HERE/../assets/sfx"
      MUSIC=""
      PAD_COLOR="#ffffff"
      ALSO_1X1=0
      CHAT="" END="" SFX_JSON="" OUT=""
      
      while [ $# -gt 0 ]; do
        case "$1" in
          --chat) CHAT="$2"; shift 2;;
          --end) END="$2"; shift 2;;
          --sfx) SFX_JSON="$2"; shift 2;;
          --out) OUT="$2"; shift 2;;
          --music) MUSIC="$2"; shift 2;;
          --sfx-dir) SFX_DIR="$2"; shift 2;;
          --pad-color) PAD_COLOR="$2"; shift 2;;
          --also-1x1) ALSO_1X1=1; shift;;
          *) echo "unknown arg: $1" >&2; exit 1;;
        esac
      done
      
      for v in CHAT END SFX_JSON OUT; do
        [ -n "${!v}" ] || { echo "missing --${v,,}" >&2; exit 1; }
      done
      for f in "$CHAT" "$END" "$SFX_JSON"; do
        [ -f "$f" ] || { echo "MISSING: $f" >&2; exit 1; }
      done
      
      # The 4 SFX wavs are subliminal stand-ins. If a catalog fetch didn't materialize
      # the binary assets, synthesize them here so a missing wav never breaks the render
      # (the cue loop adds each as an ffmpeg input unconditionally). Swap real wavs anytime.
      mkdir -p "$SFX_DIR"
      _synth() { [ -f "$SFX_DIR/$1.wav" ] || ffmpeg -y -f lavfi -i "$2" -ar 44100 -ac 2 "$SFX_DIR/$1.wav" >/dev/null 2>&1; }
      _synth key-tap       "sine=frequency=1800:duration=0.05"
      _synth send-tap      "sine=frequency=900:duration=0.08"
      _synth stream-tick   "sine=frequency=2400:duration=0.03"
      _synth response-done "sine=frequency=600:duration=0.12"
      
      # Chat is the master canvas — never scale/stretch it. Normalize the end card to
      # the chat's exact dimensions (scale-to-fit + pad with the card's bg color).
      CW=$(ffprobe -v error -select_streams v:0 -show_entries stream=width  -of csv=p=0 "$CHAT")
      CH=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$CHAT")
      
      # 1) Crossfade chat → (normalized) end card (300ms).
      CHAT_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$CHAT")
      END_DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$END")
      XFADE=0.30
      TOTAL=$(python3 -c "print($CHAT_DUR + $END_DUR - $XFADE)")
      XSTART=$(python3 -c "print($CHAT_DUR - $XFADE)")
      TMP_VIDEO=$(mktemp -t gpt-stitch).mp4
      ffmpeg -y -i "$CHAT" -i "$END" \
        -filter_complex "[1:v]scale=${CW}:${CH}:force_original_aspect_ratio=decrease,pad=${CW}:${CH}:-1:-1:color=${PAD_COLOR},setsar=1[end];[0:v][end]xfade=transition=fade:duration=${XFADE}:offset=${XSTART}[v]" \
        -map "[v]" -an -c:v libx264 -pix_fmt yuv420p -movflags +faststart "$TMP_VIDEO" >/dev/null 2>&1
      echo "  video stitched, ${TOTAL}s (${CW}x${CH})"
      
      # 2) Build the audio mix (subliminal ChatGPT SFX cues [+ optional ducked bed]).
      TMP_AUDIO=$(mktemp -t gpt-audio).m4a
      MUSIC_ARG="${MUSIC:-NONE}"
      python3 - "$SFX_JSON" "$SFX_DIR" "$MUSIC_ARG" "$TOTAL" "$TMP_AUDIO" <<'PY'
      import json, sys, subprocess
      sfx_json, sfx_dir, music, total, out = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4]), sys.argv[5]
      doc = json.load(open(sfx_json))
      cues = doc["cues"] if isinstance(doc, dict) else doc
      has_music = music != "NONE"
      
      # Subliminal per-name gains (linear ≈ dBFS): ChatGPT has no native chime so the
      # SFX are felt, not heard.
      GAIN = {"key-tap": 0.040, "send-tap": 0.100, "stream-tick": 0.025, "response-done": 0.079}
      
      # Base: a silent stereo bed of full length so amix always has an anchor.
      inputs = ["-f", "lavfi", "-t", str(total), "-i", "anullsrc=r=44100:cl=stereo"]
      filter_parts = []
      mix_labels = ["[0:a]"]
      idx = 1
      if has_music:
          inputs += ["-i", music]
          # Ducked bed: loop to length, drop rumble, sit low so the streamed answer
          # reads, 1.5s fade-out so the end-card CTA lands in (relative) quiet.
          filter_parts.append(
              f"[{idx}:a]aloop=loop=-1:size=2147483647,atrim=duration={total},"
              f"highpass=f=60,volume=0.22,afade=t=out:st={max(0,total-1.5)}:d=1.5[mus]")
          mix_labels.append("[mus]")
          idx += 1
      for c in cues:
          name = c["name"]
          sfx_file = f"{sfx_dir}/{name}.wav"
          inputs += ["-i", sfx_file]
          delay = int(round(c["t"] * 1000))
          vol = GAIN.get(name, 0.05)
          filter_parts.append(f"[{idx}:a]adelay={delay}|{delay},volume={vol}[s{idx}]")
          mix_labels.append(f"[s{idx}]")
          idx += 1
      n = len(mix_labels)
      # normalize=0 so amix doesn't divide each input by N. No global volume boost
      # (unlike iMessage) — these cues are meant to stay subliminal; just limit peaks.
      filter_parts.append(
          "".join(mix_labels) +
          f"amix=inputs={n}:duration=first:dropout_transition=0:normalize=0,alimiter=limit=0.95[aout]")
      fc = ";".join(filter_parts)
      cmd = ["ffmpeg", "-y"] + inputs + ["-filter_complex", fc, "-map", "[aout]", "-c:a", "aac", "-b:a", "192k", out]
      subprocess.run(cmd, check=True, stderr=subprocess.DEVNULL)
      print(f"  audio: {'1 music bed + ' if has_music else ''}{len(cues)} subliminal sfx cues")
      PY
      
      # 3) Mux video + audio → master.
      ffmpeg -y -i "$TMP_VIDEO" -i "$TMP_AUDIO" -map 0:v -map 1:a -c:v copy -c:a aac -shortest -movflags +faststart "$OUT" >/dev/null 2>&1
      rm -f "$TMP_VIDEO" "$TMP_AUDIO"
      echo "  master → $OUT"
      
      # 4) Optional 1:1 variant — center-CROP a square from the chat zone (never scale
      #    to a different ratio; cropping is the only legitimate shape change).
      if [ "$ALSO_1X1" = "1" ]; then
        OUT_1X1="${OUT%.mp4}-1x1.mp4"
        CY=$(python3 -c "print(max(0, int(($CH - $CW) / 2)))")
        ffmpeg -y -i "$OUT" -vf "crop=${CW}:${CW}:0:${CY}" \
          -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k -movflags +faststart "$OUT_1X1" >/dev/null 2>&1
        echo "  1:1    → $OUT_1X1"
      fi
      
  • tests
    • smoke-test.md 2.4 KB
      # Smoke test — render-chatgpt-chat
      
      Verifies the free assembly end-to-end from the bundled example config. No paid
      calls. Needs: Node, `npm install` (Playwright Chromium), and ffmpeg/ffprobe.
      
      ## Setup
      
      ```bash
      cd scripts
      npm install
      npx playwright install chromium
      mkdir -p /tmp/gpt-chat-smoke
      ```
      
      ## Run
      
      ```bash
      node record-chat.js     --config config.example.json --out-dir /tmp/gpt-chat-smoke
      node render-end-card.js --config config.example.json --out-dir /tmp/gpt-chat-smoke
      bash stitch.sh \
        --chat /tmp/gpt-chat-smoke/master-chat.mp4 \
        --end  /tmp/gpt-chat-smoke/scene-end-endcard.mp4 \
        --sfx  /tmp/gpt-chat-smoke/master-chat.sfx.json \
        --out  /tmp/gpt-chat-smoke/master-final.mp4 \
        --pad-color "#ffffff" \
        --also-1x1
      ```
      
      ## Expect
      
      - `master-chat.mp4` — 750×1624 (~9:19.5), ~12s, ONE continuous take: the user
        types with the keyboard up, taps send (bubble + keyboard-down + header-swap in one
        beat), one gray loading dot for ~500ms, then the assistant answer streams in
        word-by-word. No micro-flicker / scene cuts.
      - `master-chat.sfx.json` — `{ "cues": [ … ] }` with key-tap / send-tap / stream-tick
        / response-done entries and **no cue on the loading dot**.
      - `scene-end-endcard.mp4` — 1080×1920, ~2.5s designed end card (wordmark + ⭐ proof
        row + trust trio + CTA pill).
      - `master-final.mp4` — chat crossfades into the end card (padded to 750×1624 with the
        card bg so the seam is invisible); audio carries the subliminal SFX (and the ducked
        bed if `--music` was passed). `master-final-1x1.mp4` is a 750×750 center crop.
      - `ffprobe` confirms dimensions/duration; run the `watch` skill on the master to
        confirm beat order, the single loading dot, and word-by-word streaming.
      
      ## Fail signals
      
      - Chat looks horizontally fat/stretched → an export forced a non-9:19.5 ratio onto
        the 750×1624 master. Never scale the chat to a different ratio (the 1:1 crop is the
        only legitimate shape change).
      - Assistant text appears all at once → the assistant message is missing
        `"stream": true` (the mockup wraps words only when streaming).
      - Keyboard visible while the answer streams → a `keyboard-hide` event is missing at
        the send-tap `t`.
      - Three dots instead of one → wrong row type; use a single `loading-dot` message.
      - Visible seam under the end card → `--pad-color` didn't match `end_card.bg`.
      - `Cannot find module 'playwright'` → run `npm install` in `scripts/`.
      
  • SKILL.md 7.8 KB
    ---
    name: render-chatgpt-chat
    description: Assemble a ChatGPT chat-reveal video ad from a thread + timeline JSON — one continuous Playwright recording of a ChatGPT mobile chat (user types with the iOS keyboard up → taps send → keyboard slides down + header cluster swaps in one beat → one gray loading dot → the assistant answer streams in word-by-word) crossfaded into a designed end card, with subliminal ChatGPT SFX and an optional ducked music bed. FREE assembly (Playwright + ffmpeg); the recipe supplies the per-brand thread + timeline + end-card config and gates the paid music call to its own capability. The ChatGPT sibling of render-imessage-chat. Use for the chatgpt-chat format.
    status: active
    ---
    
    # render-chatgpt-chat
    
    The free renderer for the **chatgpt-chat** video ad format — the "I just asked
    ChatGPT…" creative, where someone asks ChatGPT a question and the *streamed
    assistant answer is the punchline* (the brand surfacing as the natural response).
    Deterministic Playwright + ffmpeg assembly; no generative video of the UI, so the
    bubble text and streamed answer stay pixel-crisp.
    
    This is the **ChatGPT sibling of `render-imessage-chat`**. Reach for this one when
    ChatGPT is the more credible host for the answer; reach for iMessage when the
    punchline is a peer's reaction in a DM. The template recipe (DB) supplies the
    per-brand `thread` + `timeline` + `end_card` config and gates the paid music call
    (music bed → `create-music-elevenlabs`) to its own capability.
    
    ## What it renders
    
    One continuous take — never scene-by-scene (every reload flickers):
    
    1. **User types** in the composer with the iOS keyboard up (`composer-type`).
    2. **Send-tap is ONE beat** — the user bubble pops, the keyboard slides down, and
       the header right-cluster swaps (`personPlus/dottedCircle` → `edit/more`) all on
       the same `t`. Never sequence them across frames.
    3. **One gray loading dot** holds ~500ms (never three — three reads as iMessage
       typing, wrong app), silently (no SFX on the dot).
    4. **The assistant answer streams in word-by-word** (`stream-words`, ~7 wps) with a
       soft opacity ramp; the conversation auto-scrolls to keep it in view.
    5. **Crossfade to a designed end card** (wordmark + ⭐ proof row + trust trio + CTA
       pill) and **mux a ducked music bed** → master MP4.
    
    The chat records at the ChatGPT-native **~9:19.5 (default 750×1624)** to match a real
    iPhone screen recording. **Never stretch the chat to a different aspect ratio** — the
    end card is scaled-to-fit + padded to the chat's dimensions in stitch, so the chat
    is never touched.
    
    ## Run
    
    ```bash
    cd scripts && npm install            # once — installs Playwright
    npx playwright install chromium      # once
    node record-chat.js     --config config.json --out-dir <work>   # → master-chat.mp4 + .sfx.json
    node render-end-card.js --config config.json --out-dir <work>   # → scene-end-endcard.mp4
    bash stitch.sh --chat <work>/master-chat.mp4 --end <work>/scene-end-endcard.mp4 \
         --sfx <work>/master-chat.sfx.json --out <work>/master-final.mp4 \
         --pad-color "#ffffff" [--music <work>/music-bed.mp3] [--also-1x1]
    ```
    
    1. **`record-chat.js`** — reads `config.json` (`thread` + `timeline` + geometry),
       renders the bundled `create-chatgpt-mockup` HTML once with every message
       pending, walks the timeline on `requestAnimationFrame` inside the page, records
       it as one continuous MP4, and emits the deterministic SFX cue list.
    2. **`render-end-card.js`** — fills `end-card.template.html` from `config.end_card`
       (wordmark/`logo_svg`, stars, proof, trust trio, CTA, colors) → still MP4. This is
       the SAME generic end card as `render-imessage-chat` (copied verbatim).
    3. **`stitch.sh`** — normalizes the end card to the chat's dimensions, crossfades
       chat → end card, layers the subliminal ChatGPT SFX, optionally ducks a music bed
       under it, and optionally derives a 1:1 crop. All FREE ffmpeg. Pass
       `--pad-color` = `end_card.bg` (default `#ffffff`, ChatGPT light mode) so the pad
       under the end card is seamless.
    
    ## The chat body: bundled create-chatgpt-mockup
    
    The ChatGPT chat HTML comes from **`create-chatgpt-mockup`** (its `generate.js` +
    `templates/` produce the light-mode ChatGPT iOS HTML — status bar, header, message
    rows, streaming word-spans, composer, and the inline iOS keyboard). Those files are
    **bundled into `scripts/mockup/`** so this capability renders the chat body
    **standalone** — no sibling fetch of `create-chatgpt-mockup` is required. `record-chat.js`
    does `require('./mockup/generate.js')`.
    
    The keyboard is inlined by the mockup (`renderKeyboard`) — no separate keyboard atom.
    
    ## Timeline events (consumed by record-chat.js)
    
    | Kind | Meaning |
    |---|---|
    | `composer-type` | `{ text, dur_sec }` — type into the composer. SFX = one key-tap per word. |
    | `composer-clear` | Wipe the composer instantly (fire at send-tap). |
    | `keyboard-show` / `keyboard-hide` | Slide the iOS keyboard up / down. |
    | `send-tap` | Pulse the send button. SFX = send-tap. |
    | `pop` | `{ target: <msg-id> }` — reveal a message row. |
    | `header-swap` | `{ value: "alt" }` — swap the header right-cluster. |
    | `loading-dot-show` / `loading-dot-hide` | `{ target: <dot-id> }` — the single gray dot. |
    | `send-state` | `{ value: "streaming"\|"active" }` — composer send-button state. |
    | `stream-words` | `{ target, dur_sec, wps }` — reveal the assistant answer word-by-word. SFX = stream-tick every 12 words + response-done at the end. |
    | `scroll-to` | `{ target, dur_ms }` — smooth-scroll a row into view. |
    
    See `scripts/config.example.json` for the canonical thread + timeline (the "one
    beat" send-tap and the streamed list answer are both wired there).
    
    ## Contract
    
    - FREE assembly: Playwright record + ffmpeg composite/mux + the bundled SFX. No
      AI-rendered text — the bubbles, the streamed answer, and the end-card copy are all
      real HTML/PIL, never invented by a model.
    - The recipe (DB) supplies the per-brand config: the `thread` (light-mode ChatGPT,
      assistant message set `stream: true`), the `timeline`, the `end_card` (prefer a
      real `logo_svg` wordmark), and an optional music bed.
    - SFX are **subliminal by design** (ChatGPT has no native chime): key-tap -28dB,
      send-tap -20dB, stream-tick -32dB, response-done -22dB, and **never a cue on the
      loading dot**. Set `"sfx": false` in the config to ship the chat silent.
    
    ## Gaps / routing notes
    
    - **Music bed** is an input, not generated here — the recipe gates it to
      `create-music-elevenlabs` (paid, proxy-routed, billed to the Ads agent) and passes
      the file into `stitch.sh --music`.
    - **Bundled SFX are synthesized stand-ins.** The original four wavs
      (`key-tap`/`send-tap`/`stream-tick`/`response-done`) were lost from Git LFS (the
      objects 404 on the server), so `assets/sfx/*.wav` are freshly synthesized
      subliminal clicks/ticks. They work as-is; swap in real wavs (same filenames) for
      tuned SFX.
    - **Portability:** everything runs from the fetched
      `/tmp/gooseworks-scripts/render-chatgpt-chat/scripts/…` — the chatgpt-mockup
      generator + templates are bundled under `scripts/mockup/`, and the generic end
      card is bundled under `scripts/`. No `/Users/…` or repo-relative paths, and no
      required sibling fetch.
    - Requires **ffmpeg/ffprobe** on PATH and Playwright Chromium (`npx playwright
      install chromium`).
    
    ## Self-QC (per project rule — always /watch the master)
    
    - Keyboard is up the whole time the user types, and slides down only on the
      send-tap beat (never visible while the answer streams).
    - Send-tap is one beat: user bubble + keyboard-down + header-swap on the same frame.
    - Exactly one gray loading dot for ~500ms (not three), and no SFX on the dot.
    - The answer streams word-by-word, left-to-right / top-to-bottom, not all-at-once.
    - No OpenAI spiral logo above any assistant title (the spiral is empty-state only).
    - No micro-flicker / scene cuts; the end-card pad color matches `end_card.bg`.
    
  • skill.meta.json 315 B
    {
      "slug": "render-chatgpt-chat",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-chatgpt-chat",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related