| name | create-imessage-video-ad |
| description | Produce a 9:16 social-native ad that recreates an iMessage conversation reveal — bubbles pop in over time, composer types char-by-char, real Apple iMessage SFX hit on every send/receive, music bed underneath, brand end card with FREEPACK-style CTA. One continuous Playwright recording (no scene-cuts/glitches) in plain-chat or iPhone-framed (Dynamic Island over a background) variants, assembled to a master MP4 plus 9:16 / 1×1 export variants. |
create-imessage-video-ad
Originally validated in content-goose at clients/clinikally/video-02-imessage-goa-sunscreen/ (newest), clients/catchback-cards/ads/video-01-imessage-chat/, and clients/peloton/ads/video-04-imessage-ironman/. Adapt paths to your project.
Purpose
Use this skill when an ad concept calls for an authentic iMessage thread reveal: someone screenshots a product/result, sends it to a friend, the friend reacts and asks what app/service it is, and the conversation surfaces the brand + a CTA code. Produces a 9:16 social-native ad with real Apple iMessage SFX, optional music bed, and brand end card — one continuous Playwright recording assembled into a master MP4 plus 9:16 / 1×1 variants.
Reference builds
Three reference builds — copy the closest one as your starting point:
clients/clinikally/video-02-imessage-goa-sunscreen/ — NEWEST; default starting point. iPhone-frame variant (Variant B: framed phone + Dynamic Island over a brand-relevant background photo), dark theme, native 1080×1920 recording, full composer drives on every sent bubble, NB2-generated hook/background/end-card-base assets, reference-styled end card. Friend-asks-friend inverse angle. 21.2s.
catchback-cards/ads/video-01-imessage-chat/ — abstract-glow end card, gray peer avatar, product-flex hook (screenshot of a graded card with a price). 20.6s. Playful brand voice. Plain-chat variant.
peloton/ads/video-04-imessage-ironman/ — photo-background end card with real Peloton wordmark, indigo peer avatar, result-flex hook (Strava finish-line summary rendered locally as HTML→PNG). 19.0s. Grounded brand voice. Plain-chat variant.
When to use
- Reaction/discovery ads where the punchline is the recipient's curiosity ("what app is that?", "wait this is real?")
- Promo-code reveals (FREEPACK / FIRSTPACK / WELCOME10) — the conversational delivery feels far less ad-like than a hard CTA card alone
- Any time the brief mentions "fake DM", "screenshot of a chat", "iMessage style", "Tyler/peer"
If the chat is just one beat (a single screenshot), reach for the atom skills/atoms/messaging/create-imessage-mockup directly. Use this molecule only when you need a multi-bubble timeline animated to video.
Composed Atoms
skills/atoms/messaging/create-imessage-mockup — renders the iMessage HTML (plain or with-iphone-frame mode); this skill drives it via Playwright
skills/atoms/assembly/stitch-videos-ffmpeg — final concat + crossfade
skills/atoms/_shared/fal_helpers.py — (optional) NB2 (fal-ai/nano-banana-2) generation of photographic hook / background / end-card-base assets, Clinikally-style
- (optional) ElevenLabs music generation OR a music asset already in the repo (search
**/music*.mp3)
Inputs
brief (required) — what the ad is selling, the CTA code, the peer's name (e.g. "Tyler"), the screenshot subject (product image path or AI-generated frame)
output_folder — usually <brand>/ads/video-NN-imessage-<slug>/
render_variant — plain (full-bleed chat, the catchback/peloton builds) or iphone-frame (framed phone + Dynamic Island over a background photo — the Clinikally build; default for new builds, it reads more native in-feed). iphone-frame additionally needs a background_asset (brand-relevant photo; NB2-generate if none exists).
peer_persona — name + 2-letter monogram/initials + avatar bg color (hex) — i.e. the non-self entry in the thread's participants[]
screenshot_asset — path to the image that gets sent in the first attachment bubble
bubbles — list of {from: me|peer, text: string, delivered?: bool, typing_before?: bool}
composer_drives — the composer text for every sent bubble ({bubble_id, text, dur_sec}). The typed text must equal the sent text — see Critical knowledge #13.
end_card — {wordmark, code, tagline} (+ optionally a reference_image for the reference-styled variant) — used to render the brand slate
The master thread.json schema is identical to the create-imessage-mockup atom (typed messages[] — text / typing / attachment with src/title/subtitle — plus participants[], theme, header); this skill adds no new data structure. The only new artifact is the timeline (see Workflow step 3).
Critical knowledge — read before producing your first ad
These are mistakes that have already been made and fixed in the canonical builds. Do not re-introduce them.
0. Use the real brand wordmark on the end card, never styled text
CSS approximations of brand wordmarks look amateur even when typeface and kerning are close. The Peloton build caught this on first preview: a hand-styled "peloton" lowercase + manual dot got rejected immediately in favor of the real Wikimedia SVG. Drop the official SVG at assets/<brand>-logo.svg (sources: Wikimedia Commons, brandfetch.com/<brand>.com, brand press kit) and the bundled render-end-card.template.js injects it via the <!--{{BRAND_LOGO_SVG}}--> placeholder. Your only job is to color the paths.
See references/end-card-recipe.md for the full end-card playbook (CTA pill color, logo contrast tricks, photo-background composition).
1. Use the real Apple iMessage SFX, not generic notification sounds
The "iMessage feel" is 70% the SFX. Generic notification sounds break the illusion immediately. The Apple sounds are CC0 on BigSoundBank:
- Send (whoosh-up):
https://bigsoundbank.com/UPLOAD/mp3/1313.mp3 — Apple "Message Sent" (~0.5s)
- Receive (tritone):
https://bigsoundbank.com/UPLOAD/mp3/1111.mp3 — Apple "Note" (raw is 6s; trim to 1.4s with a 400ms fade-out)
Pre-bundled copies live at assets/sfx/imessage-send.mp3 and assets/sfx/imessage-receive.mp3 in this skill. Reuse those instead of re-downloading. Both are loudness-normalized to -9 LUFS, peak 0dB so they cut through the music bed without further tweaking.
LFS gotcha: these files are git-lfs-tracked. If you cp them into a fresh ad folder and ffmpeg later reports Invalid data found when processing input, you got a 130-byte pointer file instead of the audio. Recover by running git lfs pull --include="skills/molecules/create-imessage-video-ad/assets/sfx/*" from the repo root, OR re-fetch from BigSoundBank with the recipe in #1 above.
2. NEVER play SFX on the typing indicator
The typing-dots bubble appearing is a silent state change — iOS does not chime when someone starts typing. Adding a soft "receive" cue on the typing-pop sounds wrong even when quiet. Only fire receive SFX when the actual text bubble lands (i.e. on typing-swap).
This was caught on review of the canonical build and explicitly removed.
3. Record the entire chat as ONE continuous Playwright session
Do not record scene-by-scene and concat. Every page reload causes a micro-flicker at the cut, and "scrolling" has to be faked by removing older bubbles between scenes — which looks janky because it is janky.
Architecture:
- Render the full thread HTML with all messages present but each marked
popState: 'pending' (which the CSS turns into data-pending="1" → display:none).
- A single embedded driver script walks a
TIMELINE array on requestAnimationFrame, removing data-pending and adding pop-now at the right moment for each event.
- Auto-scroll the body via custom easing whenever a new bubble lands; do not rely on
scrollIntoView (inconsistent in headless).
- Compute the SFX cue list deterministically from the timeline (do not capture cues via
performance.now() inside the page — there's a race with setContent's load event that drops cues).
scripts/record-master.template.js implements this end-to-end. Adapt the TIMELINE array and reuse the rest verbatim.
4. Pop-pending must remove rows from layout, not just hide them
If pop-pending only sets opacity: 0, the row still occupies its full height — so the conversation pre-allocates all 15 bubbles' worth of space at frame 0, the auto-scroll has nothing to scroll to, and bubbles "pop into pre-allocated holes" instead of arriving. Use display: none (via data-pending="1") so the conversation grows naturally as messages arrive.
The atom's chat.css now has this rule baked in:
.row[data-pending="1"], .delivered-caption[data-pending="1"] {
display: none !important;
}
5. End card stays static — no Ken-Burns, no zoom-pan
The brand slate must land hard. A drifting end card reads as filler and undercuts the punch of the FREEPACK reveal. Render the end-card PNG once and ffmpeg it to a fixed-frame MP4:
ffmpeg -y -loop 1 -i end-card.png -t 3.5 -r 30 \
-vf "scale=720:1280,format=yuv420p" \
-c:v libx264 -pix_fmt yuv420p -movflags +faststart end-card.mp4
Crossfade from chat → end card with a 300ms xfade in the stitch step (longer feels mushy, shorter feels like a hard cut).
6. ffmpeg amix divides by N inputs by default
If you amix the music + the silent base + 11 SFX, every input gets divided by 13 unless you pass normalize=0. The audio will sound mysteriously quiet. Always:
amix=inputs=N:duration=first:dropout_transition=0:normalize=0
Then take the mix through volume=2.5,alimiter=limit=0.95 so it peaks at 0dB without clipping.
7. Music bed: lofi/hip-hop, 60Hz highpass, ~-10dB
Keep the bed unobtrusive — volume=0.30 plus highpass=f=60 to clear room for the SFX. Fade out 1.5s before the end so the FREEPACK reveal can land in (relative) silence. If the bed is shorter than the ad, loop it in the mix with aloop=loop=-1:size=2147483647,atrim=duration=<total> (the Clinikally stitch does this) instead of hunting for a longer track. Any short lofi/hip-hop instrumental at <your-project>/audio/music-bed.mp3 works (lab example: coca-cola/ad-runs/video-01-museum-painting/audio/variants/A_hiphop/music.mp3); generate one via ElevenLabs music if the repo has none.
8. Hook asset — HTML-mimic for app UIs, NB2 for photographic shots
Route by what the hook is:
- App-UI screenshots (Strava, Slack, a cancellation email, a statement): write an HTML file
that mimics the real app's UI and render it to PNG with Playwright (~5 lines of script).
AI-generating fake app UIs is unreliable — garbled chrome reads as slop.
- Photographic / lifestyle shots (a throwback beach photo, a selfie-ish flex, the framed
variant's background): generate with Nano Banana 2 (
fal-ai/nano-banana-2/text-to-image,
via skills/atoms/_shared/fal_helpers.py) — the Clinikally build generated its Goa throwback,
beach background, and end-card base this way. Write each generator as a small
working/gen_<asset>.py and save a <asset>.meta.json sidecar (prompt + model) next to the
output for provenance.
Two rules:
- Mimic the actual UI patterns, not just the vibe. Abstract gradients + silhouettes read as "AI slop" (the first Peloton Strava attempt). Specific UI conventions (Strava-orange brand strip, polyline route, "Public · 2h ago" timestamp, real-feeling stats grid) read as real (the second attempt).
- Match the app's brand colors and typography exactly. Get them from the app's marketing site or screenshots.
See examples/strava-card.example.html for the canonical pattern. Render with:
const { chromium } = require('playwright');
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 720, height: 880 }, deviceScaleFactor: 2 });
const page = await ctx.newPage();
await page.setContent(fs.readFileSync('strava-card.html', 'utf8'), { waitUntil: 'load' });
await page.waitForTimeout(150);
await page.screenshot({ path: 'strava-card.png' });
9. Pacing curve — not flat 700ms gaps
Real iMessage banter has rhythm:
- One-word reactions ("bro no way" → "is that on an app"): 250–450ms apart
- Sentence reveals (after composer typing): 600–900ms before the next event
- Beat of silence (~600ms) before the final "bet" so it lands
The canonical timeline in record-master.template.js is a good starting point — adjust event times rather than redesigning from scratch.
10. Variant B — iPhone-frame mode (the Clinikally pattern; default for new builds)
Render with the atom's framed mode — renderHTML(thread, { mode: 'with-iphone-frame' }) — so the
chat plays inside a phone (Dynamic Island + status bar) over a brand-relevant background photo.
Three gotchas the Clinikally build hit:
- The atom's framed branch hard-codes light mode. Inject dark manually after render:
html.replace('<html>', '<html class="theme-dark">').replace('<body class="framed">', '<body class="framed theme-dark">').
- Layout math: the framed UI is laid out at 514×914 logical and scaled to the 1080×1920
output via
html { zoom: 2.10 } (514 × 2.10 ≈ 1080). Use fixed-px heights, not vh, in
every override.
- Scoped style overrides (see the Clinikally
styleB()): background photo as a data-URI on
body.framed, compact conv-header (min-height 66px, 42px avatar), .conversation with
overflow: hidden; justify-content: flex-end, attachment cards capped at max-width: 58%.
In framed mode the scroller is .conversation, not the document — the driver must scroll
that element. And the framed keyboard's .input starts empty: rebuild it on the first composer
keystroke (inject .composer-text + caret + send button — the driver's composerSpan() does
this).
11. Record at the NATIVE output resolution
Playwright's recordVideo cannot upscale — set viewport == recordVideo.size == the output size (1080×1920, deviceScaleFactor 1) and let the zoom carry the layout to full size. The old
720×1280-then-lanczos-upscale flow ships a soft master; native recording is visibly crisper,
especially bubble text.
12. Trim the paint offset so SFX cues align exactly
recordVideo starts rolling at newContext(), but the page doesn't paint the iMessage shell
until ~500ms later — so MP4 t=0 ≠ TIMELINE t=0 and every SFX cue lands late. Measure the delta
(Date.now() at context creation → __driverReady after setContent), then trim it off the
head: ffmpeg -ss <offset> -i <recording> -t <TOTAL_DURATION> …. After the trim, the cue list's
t values line up with the video exactly.
13. EVERY sent bubble gets a full composer drive
The composer-typed text must equal the sent bubble's text — a partial composer preview that
"sends" a longer bubble reads as fake on a second watch. Pace at ~12–15 chars/sec with ±30%
per-character jitter (perChar * (0.7 + Math.random() * 0.6)) so the typing feels like thumbs,
not a metronome. Pair each send: composer (dur ≈ chars/14) → pop + composer-clear at the
same t.
Concept catalog
Most iMessage ads fit one of these six angles. Use them as a brainstorming menu, not a checklist. Strongest hooks across all six: a specific number, a small act of self-trust, or a physically novel product mechanic.
| Angle | Hook (the attachment) | Reveal |
|---|
| Result-as-screenshot | a number that brags by itself — race time, app summary, dashboard, FTP score | "X mins a day. that's it." |
| Setup flex | photo of your space — tiny apartment, race-kit corner, home gym in a closet | "this is the whole gym" |
| Cancellation moment | confirmation receipt — gym cancellation email, subscription cancelled page | "$X → $Y, do the math" |
| Feature-as-punchline | short video clip of the feature in motion — rotating screen, transforming product | the mechanic is the brand |
| Friend-asks-friend (inverse) | the peer initiates with the wow — "how are you doing this 😭" | you reply with the brand |
| Receipt-as-hook | mundane financial document — credit-card statement, App Store receipt | small act of self-trust |
Pick the strongest angle for the brand voice before writing copy. Most "the script is fine but feels off" feedback comes from picking the wrong angle, not bad copy. Catching that here is 30 seconds; catching it after recording is 30 minutes.
End-card variants — which to pick
The molecule ships two templates. Pick by brand voice, not aesthetic preference. See references/end-card-recipe.md for the full playbook.
| Template | When to pick |
|---|
end-card.template.html (abstract glow + product) | Brand voice is playful or product-led. Hook is a thing (CatchBack). |
end-card-photo-bg.template.html (athlete/persona photo) | Brand voice is grounded or lifestyle-led. Hook is a person (Peloton). |
| Reference-styled (NB2 base + HTML overlay) | A specific end-card look is requested or referenced. NB2-generate the base image from the reference (Clinikally: working/gen_endcard_v2.py from source/pellops-endcard-reference.jpeg), then overlay the real wordmark + CTA in end-card.html. Text/logo stay HTML-crisp; only the backdrop is generated. |
Workflow
State 0 — Brainstorm angles (skip only if direct port of a reference)
Generate 4–5 concept angles from the catalog above before writing thread.json. For each:
- One-line hook (what gets screenshotted)
- 8–14 bubble script as a markdown table
- Why it works in one sentence
Show the menu to the user. Lock the angle before writing State 1's full script.
State 1 — Capture the script + screenshot asset
Either transcribe a reference ad with /watch <reference.mp4> or write the script from scratch. You need:
- The verbatim text of every bubble in order
- Who sends each one (me / peer)
- Where typing indicators belong (typically before each peer reply that's a sentence, optional for short reactions)
- The composer-text snippets (mid-typing previews of selected sent bubbles)
- The exact CTA code
State 2 — Build threads/full-thread.json + pick the render variant
Match the schema in skills/atoms/messaging/create-imessage-mockup/SKILL.md — extended with:
theme: "dark" (mandatory for this format)
header.style: "conversation" and header.unread: <number> (the "252" badge in the reference)
- The first message is usually
{type: "attachment", from: <whoever drops the hook>, src, title, subtitle} — the screenshot/photo the chat is about (the inverse angle has the PEER drop it)
- Inline
[[link:CODE]] markers in the bubble text where iOS would auto-detect a code/email/URL — they render with the link-detector underline
Pick the render variant now (it changes the recorder, not the thread): iphone-frame
(default — Clinikally pattern, needs a background photo; see Critical knowledge #10) or plain
(full-bleed chat — catchback/peloton pattern). Attachment src paths get base64-inlined by the
recorder (inlineAttachments) so setContent never chases relative file paths.
See examples/full-thread.example.json (CatchBack, plain) and
clients/clinikally/video-02-imessage-goa-sunscreen/threads/full-thread.json (framed, inverse
angle) for canonical threads.
State 3 — Define the TIMELINE
In clips/record-master.js, the TIMELINE array is the single source of truth for both the recording schedule and the SFX cue list. Each event:
{ t: <seconds>, kind: '<kind>', id: '<msg-id>', sfx: '<send|receive|null>' }
Kinds:
pop — reveal a text or attachment bubble (sfx: 'send' or 'receive' per direction)
typing-pop — reveal a typing-dots bubble (ALWAYS sfx: null — see knowledge #2)
typing-swap — replace a typing bubble with the actual text (sfx: 'receive' here)
composer — type characters into the composer over dur seconds
composer-clear — wipe the composer (paired with the pop of the message that was being composed)
scroll — smooth-scroll over dur_ms; place ~100ms after every pop and typing-pop
Pacing tip: the recorder's clock is t0 + ev.t * 1000, so all event times are absolute seconds. Sketch the whole timeline in a notepad first; the canonical 17.4s flow is in scripts/record-master.template.js.
State 4 — Record + stitch
NODE_PATH=<repo>/skills/atoms/messaging/create-imessage-mockup/node_modules \
node clips/record-master.js
NODE_PATH=<repo>/skills/atoms/messaging/create-imessage-mockup/node_modules \
node clips/render-end-card.js
bash edits/stitch.sh
scripts/stitch.template.sh is an exact copy of the canonical stitch — it consumes master-chat.sfx.json to layer the SFX deterministically.
State 5 — Export variants
Recording natively at 1080×1920 (Critical knowledge #11) means the 9:16 export is a clean
re-mux, not an upscale:
ffmpeg -y -i edits/master-final.mp4 \
-c:v libx264 -pix_fmt yuv420p -c:a copy -movflags +faststart \
meta-upload/master-9x16-1080.mp4
ffmpeg -y -i edits/master-final.mp4 -vf "crop=1080:1080:0:420" \
-c:v libx264 -pix_fmt yuv420p -c:a copy -movflags +faststart \
meta-upload/master-1x1-1080.mp4
(Legacy 720×1280 masters need the old scale=1080:1920:flags=lanczos upscale + crop=720:720:0:280
— avoid by recording native.)
State 6 — Review
Open storyboard.html in a browser; play the master against the original brief. Re-time individual events by editing the TIMELINE and re-running step 4 — no other steps need re-running.
Output
<output_folder>/
storyboard.html # the canvas — Idea / Design / Implementation tabs
threads/full-thread.json # the script as data
assets/<hook>.{png,jpg} # the image attached in bubble 1
assets/<asset>.meta.json # provenance sidecar for every NB2-generated asset
assets/<background>.jpg # iphone-frame variant only — the backdrop photo
audio/imessage-{send,receive}.mp3 # copied from this skill's assets/sfx/
audio/music-bed.mp3 # optional lofi/hip-hop bed
source/<reference>.jpeg # any reference images (e.g. end-card reference)
working/gen_<asset>.py # NB2 generators for AI-made assets (optional)
clips/
record-master.js # adapted from this skill's template
master-chat.mp4 # 17–22s continuous chat recording (1080×1920 native)
master-chat.sfx.json # deterministic cue list (paint-offset-aligned)
end-card.html # adapted from this skill's template
render-end-card.js # adapted from this skill's template
end-card.png # static brand slate
scene-end-endcard.mp4 # the static MP4 for stitching
edits/
stitch.sh # adapted from this skill's template
master-final.mp4 # 1080×1920, ~21s, h.264 + AAC, 0dB peak
meta-upload/
master-9x16-1080.mp4
master-1x1-1080.mp4
Quality Checks
Before declaring the ad shippable:
Failure Modes
| Symptom | Cause | Fix |
|---|
| Glitchy / micro-flicker between bubbles | Recorded scene-by-scene with page reloads | Use the continuous-recording pattern in scripts/record-master.template.js |
| Framed variant renders LIGHT bubbles on the phone | The atom's framed branch hard-codes light mode | Inject theme-dark onto <html> and <body class="framed"> after renderHTML (Critical knowledge #10) |
| Soft / blurry master, mushy bubble text | Recorded at 720 and upscaled, or viewport ≠ recordVideo size | Record native: viewport == recordVideo.size == 1080×1920, layout via html { zoom: 2.10 } (Critical knowledge #11) |
| SFX consistently ~0.5s late vs the bubbles | recordVideo starts at newContext() but the page paints later | Measure the paint offset and ffmpeg -ss it off the head (Critical knowledge #12) |
| Composer never shows text in framed mode | The framed keyboard .input has no text span until built | Rebuild the input on first keystroke — composerSpan() injects .composer-text + caret + send button |
| Conversation doesn't scroll in framed mode | Driver scrolled the document; the framed scroller is .conversation | Scroll the .conversation element with the eased scrollTop pattern |
| SFX too quiet, audio sounds weak | amix divided by N inputs | Add :normalize=0 to the amix filter; chase with volume=2.5,alimiter=limit=0.95 |
| Stray "Delivered" labels appear in empty space | Pop-pending didn't propagate to the caption | The atom now sets data-pending="1" on the caption alongside the bubble — make sure the atom version is current |
| Bubbles all pre-allocate space, conversation doesn't grow | Pop-pending only hid via opacity: 0 | Use display: none (via data-pending="1") — already in the atom's CSS |
| End card drifts / feels like filler | Ken-Burns zoompan in the end-card render | Use the static scale=720:1280 ffmpeg invocation in scripts/render-end-card.template.js |
| Typing dots silently appear in 1 frame and skip | The driver swapped typing→text without the typing being visible long enough | Hold typing-pop ~700ms before typing-swap |
| FREEPACK doesn't render underlined | Forgot the [[link:FREEPACK]] marker | Wrap any auto-detected code/email/URL in the marker |
| Receiver avatar clipped at top of viewport | conv-header content stack (avatar + name-pill) overflows the default conv-header height when position: fixed | The bundled styleOverride in record-master.template.js pads the header to min-height: 110px and anchors the .center stack from top: 14px (not transform centroid). Don't shrink those values without re-testing. |
ffmpeg fails on SFX with Invalid data found when processing input | LFS smudge gave you a 130-byte pointer file instead of audio | git lfs pull --include="skills/molecules/create-imessage-video-ad/assets/sfx/*" or re-fetch from BigSoundBank (see Critical knowledge #1). |
| End-card logo looks like styled text, not the brand | Used CSS-styled fallback instead of injecting the real SVG | Drop the real SVG at assets/<brand>-logo.svg and the bundled render-end-card.template.js injects it via <!--{{BRAND_LOGO_SVG}}-->. See references/end-card-recipe.md. |
| Hook screenshot looks like AI slop (gradient + silhouette) | Tried to render an "app screenshot" with abstract elements instead of the real app's UI | See examples/strava-card.example.html — mimic the actual app's brand strip, typography, and stats grid. |
Decision Rules
- Use this molecule for a multi-bubble timeline animated to video; a single static chat
screenshot is the
create-imessage-mockup atom directly.
- Render variant: default to
iphone-frame (Clinikally pattern — reads native in-feed and
carries brand context via the background). Use plain when the chat itself must fill the
frame (dense threads, small text) or when matching the catchback/peloton look.
- Use the real brand wordmark SVG on the end card — never CSS-styled text.
- Use the real Apple iMessage SFX (
assets/sfx/imessage-{send,receive}.mp3) — never generic
notification sounds.
- Never play SFX on a
typing-pop event — iOS does not chime when someone starts typing.
- Every sent bubble gets a full composer drive (typed text == sent text).
- Hook asset routing: HTML-mimic for app-UI screenshots; NB2 for photographic/lifestyle shots
(Critical knowledge #8).
Reference run
Three canonical examples. Copy the closest one and swap script + assets:
clients/clinikally/video-02-imessage-goa-sunscreen/ — newest: iphone-frame variant, dark theme, native 1080 recording, paint-offset trim, full composer drives, NB2-generated assets, reference-styled end card, friend-asks-friend inverse angle.
catchback-cards/ads/video-01-imessage-chat/ — playful brand voice, abstract-glow end card, gray peer avatar, product-flex hook. Plain variant.
peloton/ads/video-04-imessage-ironman/ — grounded brand voice, photo-background end card with real wordmark, indigo peer avatar, result-flex hook + locally-rendered Strava attachment. Plain variant.
All share the recorder architecture, stitch script, atom, and SFX. The parameters that change per build: the render variant (+ background asset), the end-card variant, the brand logo SVG, the hook asset, and the TIMELINE array.