| name | create-apple-notes-video-ad |
| description | Produce a 9:16 social-native ad recreating the iPhone Apple Notes typing experience — the note begins with 1–2 visible lines, then progressively types additional paragraphs character-by-character with per-keystroke iPhone keyboard SFX. One continuous Playwright recording, music bed underneath, brand end card with CTA. Assembled to a master MP4 plus a 9:16 social export. |
create-apple-notes-video-ad
Use this skill when an ad concept calls for an authentic Apple Notes typing reveal: a note appears with a pre-existing first line, then the user "types" the rest in front of you (more lines, more thoughts, a CTA) — punctuated by iPhone-keyboard ticks. Finishes on a brand end card.
Purpose
Produce a 9:16 social-native ad that recreates the iPhone Apple Notes typing experience: the note opens with 1–2 pre-existing lines, then progressively types additional paragraphs character-by-character with per-keystroke iPhone keyboard SFX, a music bed underneath, and a brand end card with CTA. The whole typing portion is captured as one continuous Playwright recording (no per-paragraph reloads, no scene-by-scene concatenation) so there are no micro-flickers — the only cut is a 300ms crossfade into the static end card.
The driver in scripts/record-master.template.js renders the Apple Notes screen once via the create-apple-notes-mockup atom (generateHtml()), tags the typed paragraphs, then walks a deterministic seeded timing table on requestAnimationFrame, appending one character at a time to a growing span.typed while the yellow cursor follows and the note auto-scrolls to stay above the keyboard. The same table produces a keystroke-aligned SFX cue list (master-typing.sfx.json), and scripts/stitch.template.sh mixes the music bed + per-keystroke SFX (kb-tick/kb-space/kb-return), crossfades typing → end card, and exports a master MP4 (1180×2100) plus a clean 9:16 (1080×1920) social cut.
Reach for this when the punchline is the note itself — a written-out promo code, a confession, a checklist, a calm "memo to self". For a back-and-forth chat reveal, use create-imessage-video-ad instead.
Reference build
Sample run lives at tests/output/sample-run/ (generated by bash tests/run-all.sh). It uses the canonical script "Hello / This is a screen recording of me typing on apple notes app." with three typed paragraphs landing on I'm not sure how th| mid-word, ending on a "FREEDEMO" end card. 12s total.
When to use
- Reveals where the punchline is the note itself — a written-out promo code, a confession, a checklist, a thought spiral, a "memo to self".
- Brands whose voice is calm/considered/personal (vs the rapid-fire iMessage angle).
- Sustainable-pace ads — typing typically averages ~80–140ms per character, which gives copy time to land.
If your concept is a back-and-forth chat reveal, use create-imessage-video-ad instead.
Composed Atoms
create-apple-notes-mockup — renders the light-mode Apple Notes HTML (status bar, title, body paragraphs, yellow cursor, autocorrect underline, smart quotes, floating toolbar). record-master.template.js calls its generateHtml() once with show_keyboard: true, then the molecule drives it character-by-character via Playwright.
render-ios-keyboard — supplies the iOS QWERTY keyboard chrome (suggestion bar + alpha/format rows) the Apple Notes screen shows during typing. It is static-only; the molecule owns the layout — the recorder anchors the keyboard at the shortened VIEW_H = 2100 canvas (bottom: 0) and the auto-scroll math targets the keyboard's top edge (.kbd-format/.kbd) to keep the active cursor visible.
stitch-videos-ffmpeg — final concat + crossfade + audio mux. stitch.template.sh is adapted from this atom: it xfades typing → end card (300ms), layers the per-keystroke SFX cues from master-typing.sfx.json, mixes the music bed, and exports the master plus the 9:16 social cut.
- (optional) ElevenLabs / Suno music generation OR a music asset already in the repo (the
music_bed_path in note.json).
Inputs
brief (required) — what the note says, where it pauses, what the brand sting at the end is.
output_folder — usually <brand>/ads/video-NN-apple-notes-<slug>/.
pre_typed_body — list of paragraphs already on screen at t=0 (1–2 lines is the typical sweet spot).
typed_body — list of paragraphs that get typed during the video. Each has text, type_seconds, pre_pause_seconds, optional autocorrect_underline[].
end_card — {wordmark, code, tagline, background_color, ink_color}.
The master note.json lives at <ad>/notes/note.json. Schema is in examples/full-note.example.json.
Critical knowledge — read before producing your first ad
These are mistakes that have already been made and fixed. Do not re-introduce them.
0. Use the real brand wordmark on the end card, never styled text
Drop the official SVG at assets/brand-logo.svg and scripts/render-end-card.template.js injects it via the <!--{{BRAND_LOGO_SVG}}--> placeholder. CSS-styled lookalikes read as amateur even when the typeface is close — same lesson the imessage molecule has under its own #0.
1. Use real iPhone-keyboard click SFX, not generic typing sounds
The "Apple Notes feel" is 60% the SFX — but ONLY with real iPhone clicks. Generic
mechanical-keyboard clack ruins it, and so do the bundled placeholders: the files in
assets/sfx/{kb-tick,kb-space,kb-return}.mp3 are short noise-burst stand-ins for LOCAL PREVIEW
ONLY. A final delivery ships either real iPhone clicks (source them per
assets/sfx/README.md) or silent (--no-sfx) — never the stand-ins, and never sounds you
synthesized yourself (fabricated ffmpeg noise bursts shipped once, 2026-06-11 Alitu, and read
as static). When the real clicks can't be sourced (offline, LFS stubs), silent is the correct
delivery, stated to the user.
After swapping, do NOT re-run the recorder — only re-run bash edits/stitch.sh. The cue list (clips/master-typing.sfx.json) only references files by name; the new MP3s are picked up automatically.
2. Record the entire note as ONE continuous Playwright session
Same architecture as create-imessage-video-ad — no per-paragraph reloads, no scene-by-scene concatenation. The driver walks a deterministic per-character timing table and appends one character at a time to a growing .note-paragraph > span.typed. The yellow cursor follows.
3. Pop-pending must remove from layout, not just hide
If a typed paragraph occupies its full height at t=0 (zero text inside a fixed-height <p>), the note doesn't appear to "grow" as the user types. The driver leaves each typed paragraph at min-height: 1.38em so it always occupies one line — that mirrors how an empty paragraph behaves in real Apple Notes when the user presses Return.
4. Per-character timing must be deterministic (seeded RNG)
The driver's buildTimingTable() uses an LCG seeded at 0xa5b1c2d3 so the same note.json always produces identical typing rhythm — which means the SFX cue list lines up exactly with the visual keystrokes. If you swap the seed (or use Math.random() directly), the audio will drift away from the visual after a few re-runs.
5. End card stays static — no Ken-Burns, no zoom-pan
Same rule as the imessage molecule. Render the end-card PNG once, ffmpeg it to a fixed-frame MP4 via the bundled render-end-card.template.js. Crossfade typing → end card with a 300ms xfade.
6. ffmpeg amix divides by N inputs by default
If you mix the music + the silent base + ~80 keystroke SFX, every input gets divided by 82 unless you pass normalize=0. Audio sounds mysteriously quiet. Always:
amix=inputs=N:duration=first:dropout_transition=0:normalize=0
Then take the mix through volume=2.2,alimiter=limit=0.95. Already in scripts/stitch.template.sh.
7. Music bed: ambient/lofi, 60Hz highpass, ~-11dB
Keep the bed unobtrusive: volume=0.28 plus highpass=f=60 to clear room for the typing ticks. Fade out 1.5s before the end so the brand sting lands in (relative) silence. Same envelope as the imessage molecule.
8. Pacing: typing is not uniform
Real human typing has rhythm — fast strings, slow strings, pauses on punctuation. The seeded LCG hands a 35% jitter to each character (step = avg * (0.65..1.35)). Most ads benefit from a ~0.5–0.8s pause before each new paragraph — set via typed_body[].pre_pause_seconds. That breathing room is what makes the typing feel intentional and not robotic.
9. Smart punctuation is applied by the atom, not the driver
The atom's smartQuotes() converts straight ' " ... to curly ' " … before HTML escaping. The driver inherits this for free — typing I'm yields I'm in the output. If you skip the atom and write raw HTML, you'll lose this.
10. Autocorrect underlines fire per-paragraph, on the return event
The driver fires autocorrect underlines on the return event for each paragraph — right when that paragraph finishes typing, before the cursor jumps to the next paragraph. This matches iOS behavior: the spelling layer runs at end-of-word/end-of-line, not mid-character. The end-of-run forEach still handles the last paragraph (which has no return event).
Per-paragraph words go in typed_body[].autocorrect_underline (case-sensitive list). The driver looks them up in window.__underlineMap keyed by paragraph pid and calls applyUnderline() from the return handler.
Anti-pattern: moving underline application to a single end-of-run loop. The operator sees the typed word complete without any underline for the full remaining seconds of the typing animation — reads as a bug. The per-event firing was a deliberate fix during the Bioma run; do not refactor back.
11. 9:16 delivery requires a 9:16 source canvas — never crop
The iPhone's real screen is 19.5:9 (1180×2556). Social-native delivery is 9:16 (1080×1920). Every Y-crop value forces a destructive choice: lose the status bar, or lose the bottom of the keyboard, or split the loss across both. The fix is to record at 9:16 natively: VIEW_H = 2100 in record-master.template.js plus a styleOverride that injects html, body, .screen { height: ${VIEW_H}px !important; } to override the atom's hardcoded 2556. The keyboard's position: absolute; bottom: 0 then anchors to the shorter canvas, moving the entire keyboard up ~460px naturally. The --export-9x16 step becomes a clean linear scale to 1080×1920 — no crop, no Y-offset to guess at.
The atom's static screenshots (consumed by apple-notes-skill-build.html) still render at the real iPhone 1180×2556 — only the molecule's recording uses the shortened canvas.
When changing VIEW_H, all three files must move together (the constant drifts otherwise and the xfade between typing video and end card fails silently):
record-master.template.js — const VIEW_H = 2100
render-end-card.template.js — const VIEW_H = 2100
end-card.template.html — html, body { width: 1180px; height: 2100px; }
Workflow
State 0 — Lock the script
Write the full text of every paragraph (pre-typed + typed), the per-paragraph timing, and the CTA copy. Most "the ad is fine but feels off" feedback comes from the script, not the production — get it right here.
A starter timing model:
pre_typed_body paragraph: ~3.0–4.5s on screen before typing starts
- Each typed paragraph: 1.5–2.2s typing time + 0.6–1.0s pre-pause
- Post-hold: 1.0s after the last character lands before crossfade
State 1 — Build notes/note.json
Match the schema in examples/full-note.example.json. The atom's note JSON schema is in skills/atoms/messaging/create-apple-notes-mockup/SKILL.md — every field there is honored here, plus the molecule-specific pre_typed_body / typed_body / post_hold_seconds / end_card / music_bed_path fields.
State 2 — Wire up the ad folder
Standard 5-folder layout. Inside the ad folder:
<ad>/
notes/note.json
assets/brand-logo.svg # optional but recommended
audio/sfx/kb-tick.mp3 # copied from this skill's assets/sfx/
audio/sfx/kb-space.mp3
audio/sfx/kb-return.mp3
audio/music-bed.mp3 # optional
clips/record-master.js # adapted from scripts/record-master.template.js
clips/render-end-card.js # adapted from scripts/render-end-card.template.js
clips/end-card.template.html # adapted from scripts/end-card.template.html
edits/stitch.sh # adapted from scripts/stitch.template.sh
State 3 — Record + render + stitch
NODE_PATH=<repo>/skills/atoms/messaging/create-apple-notes-mockup/node_modules \
node clips/record-master.js
NODE_PATH=<repo>/skills/atoms/messaging/create-apple-notes-mockup/node_modules \
node clips/render-end-card.js
bash edits/stitch.sh
bash edits/stitch.sh --export-9x16
State 4 — Review
Open the master in a player and watch start to finish. Re-time the typing by editing notes/note.json (the only file you'll touch for iterations) and re-running step 1 + step 3 — step 2 only needs to re-run if end_card changes.
State 5 — Export variants
--export-9x16 scales the native 1180×2556 down to 1080×2340 and center-crops to 1080×1920, retaining the keyboard and the body. Adjust the vertical crop with EXPORT_Y_CROP=<px> if your spec needs different framing (default 260, which keeps keyboard + body and trims status bar).
Output
<ad>/
notes/note.json
clips/
master-typing.mp4 # 1180×2556, no audio, ~5–15s typically
master-typing.sfx.json # deterministic cue list
end-card.png
end-card.mp4
edits/
master-final.mp4 # 1180×2556 typing + xfade + end card, h.264 + AAC
meta-upload/
master-9x16-1080.mp4 # 1080×1920 social export
Quality Checks
Before declaring the ad shippable:
Failure Modes
| Symptom | Cause | Fix |
|---|
cannot locate create-apple-notes-mockup/generate.js | Ad folder is not under a tree that contains skills/ within 8 levels up | Edit resolveAtomGenerate() in record-master.js to hardcode the path |
| Typing animation runs but no characters appear | The driver couldn't find p.note-paragraph[data-typed-pid] — usually means tagTypedParagraphs() failed because the atom's HTML changed | Update the regex in tagTypedParagraphs() to match the atom's current paragraph markup |
| SFX cues fire but no sound comes out of the final mp4 | LFS pointer files instead of audio at assets/sfx/* | git lfs pull --include="skills/molecules/create-apple-notes-video-ad/assets/sfx/*". The goose-video CLI cache clones WITHOUT LFS, so stubs are guaranteed there — re-source per assets/sfx/README.md or ship --no-sfx |
| Keyboard ticks sound like static / noise bursts | Shipped the bundled placeholder stand-ins, or synthesized substitute clicks | Placeholders are preview-only. Real iPhone clicks (assets/sfx/README.md) or --no-sfx silent — NEVER fabricate stand-in audio (live incident 2026-06-11, Alitu) |
| Final video is silent | amix divided by N inputs | Confirm normalize=0 is in the amix= filter |
| 9:16 export crops out the body | EXPORT_Y_CROP too aggressive | Lower the value (e.g. EXPORT_Y_CROP=180 bash edits/stitch.sh --export-9x16) |
| Cursor sits in the wrong paragraph at start | First typed paragraph's pre_pause_seconds is 0 | Set pre_pause_seconds >= 0.4 so the cursor blink is legible before typing kicks in |
| Music drowns out the ticks | Music volume too high | Lower volume=0.28 to 0.22 in the stitch script's music filter |
| Typing feels robotic / metronome-like | Seed jitter not applied | Confirm buildTimingTable() is using the seeded RNG, not Math.random() |
| End card's wordmark renders as styled text instead of brand SVG | assets/brand-logo.svg is missing | Drop the official SVG there; render-end-card.js injects it automatically |
Decision Rules
- Use this molecule when the reveal is the note itself typed in front of the viewer (a written-out promo code, a confession, a checklist, a memo-to-self). For a back-and-forth chat reveal, use
create-imessage-video-ad. For a single static Apple Notes screenshot (no typing animation), call the create-apple-notes-mockup atom directly.
- Pick a calm/considered/personal brand voice for this format; the typing pace (~80–140ms/char) is deliberately slower than the rapid-fire iMessage angle, so copy has time to land.
- Keep the typing portion as one continuous Playwright recording — never record per-paragraph and concat. The only cut in the whole ad is the 300ms
xfade from typing → end card.
- Per-character timing must come from the seeded LCG in
buildTimingTable() (seed 0xa5b1c2d3), never Math.random() — otherwise the SFX cue list drifts away from the visible keystrokes across re-runs.
- Use real iPhone-keyboard click SFX (
assets/sfx/kb-{tick,space,return}.mp3), never generic mechanical-keyboard sounds — the "Apple Notes feel" is ~60% the SFX. After swapping SFX, re-run only bash edits/stitch.sh; the cue list references files by name, so don't re-run the recorder.
- Record at a 9:16-native canvas (
VIEW_H = 2100), never crop a 19.5:9 iPhone screen — and move all three files (record-master.template.js, render-end-card.template.js, end-card.template.html) together whenever VIEW_H changes, or the typing↔end-card xfade fails silently.
- Use the real brand wordmark SVG on the end card (
assets/brand-logo.svg, injected via <!--{{BRAND_LOGO_SVG}}-->), never CSS-styled lookalike text.
- Keep the end card static (no Ken-Burns/zoom-pan); let the music bed (ambient/lofi,
highpass=f=60, volume=0.28, fade out 1.5s before the end) leave room for the keyboard ticks, and keep normalize=0 on the amix so the mix isn't divided by N inputs.
- Fire autocorrect underlines per-paragraph on each return event (the last paragraph is handled by the end-of-run pass) — do not refactor them into a single end-of-run loop.
- Give each new typed paragraph ~0.5–0.8s of
pre_pause_seconds so the typing reads as intentional, not robotic; set the first typed paragraph's pre_pause_seconds >= 0.4 so the cursor blink is legible before typing starts.
- Iterate on
notes/note.json only — re-run record (step 1) + stitch (step 3); re-run the end-card render (step 2) only when end_card changes.
Reference example
examples/full-note.example.json is the canonical 4-paragraph "screen recording" demo used by the test rig. Copy it as your starting notes/note.json and edit text + timings to fit your brief.