← All Skills

snappy-image

v1.0.0
72 files, 727.0 KB ~19,478 words · 78 min read Updated 2026-09-09

snappy-image skill

43 of 53 checks pass
covers 1 of 289 operations of OpenAI, checked 2026-09-09
What it can do
canva file folder?write
course-folder course-namewrite
generate topic outwrite-reversible
logo tool-namewrite
logosread
metrics metric-nameread
reframe sourcewrite
upload local-path remote-pathpost
url remote-pathread
What does not pass yet
Architecture 2 endpoints⚗ parallel
🔑 Bearer token auth
xnwv-v1z6-dvnr.n7c.xano.io2 endpoints
POST/api:PB9UH7b9/linkedin/post-image
GET/api:PB9UH7b9/linkedin/post-image
$ npx snappy-skills install snappy-image
zip ↓
File Tree
├── AGENTS.md ├── SKILL.md ├── api.ts ├── composite.md ├── contract.test.ts ├── course-cover-master-spec.md ├── data/ │ ├── brands/ │ │ ├── editorial-flat.json │ │ ├── ink-journal.json │ │ └── linear-vercel.json │ ├── logos/ │ │ ├── anthropic.png │ │ ├── aws.png │ │ ├── claude.png │ │ ├── cloudflare.png │ │ ├── contrast-audit.tsv │ │ ├── docker.png │ │ ├── fly.png │ │ ├── git.png │ │ ├── github.png │ │ ├── googlecloud.png │ │ ├── linear.png │ │ ├── neon.png │ │ ├── nextjs.png │ │ ├── nodejs.png │ │ ├── notion.png │ │ ├── openai.png │ │ ├── playwright.png │ │ ├── postgresql.png │ │ ├── python.png │ │ ├── react.png │ │ ├── redis.png │ │ ├── slack.png │ │ ├── stripe.png │ │ ├── supabase.png │ │ ├── typescript.png │ │ ├── vercel.png │ │ └── xano.png │ ├── panels/ │ │ ├── art-photography.json │ │ ├── art.json │ │ ├── business.json │ │ ├── data-journalism.json │ │ ├── narrative.json │ │ ├── technical-diagram.json │ │ ├── textbook.json │ │ └── whimsy.json │ └── prompt-learnings.md ├── entities.json ├── imageset.test.ts ├── logos.json ├── metrics.json ├── optimization.md ├── prompting.md ├── providers.md ├── rubric.md ├── scripts/ │ ├── build-logo-registry.sh │ ├── canva-sync.ts │ ├── cdn-upload.sh │ ├── classroom-mosaic.sh │ ├── classroom-retro.sh │ ├── compose-ref.sh │ ├── composite-labels.py │ ├── council.sh │ ├── generate-iterate.sh │ ├── generate.sh │ ├── image-check.py │ ├── refine.sh │ ├── reframe.py │ └── score-generation.sh ├── style.md ├── templates/ │ ├── course-cover.md │ └── course-lesson.md ├── whiteboard.md └── workflows.md
Documents
AGENTS.md

snappy-image -- Agent Loader#

You are the single producer of every image asset in the Snappy stack. You generate images via Nano Banana 2 (snappy-gemini), capture screenshots (peekaboo, agent-browser, screencapture), resize/convert via sips, composite typography via PIL, and upload finals to the DO Spaces CDN. Copy is always approved BEFORE images get generated -- if upstream copy is not locked, STOP and route to snappy-content. Every final asset lands on DO Spaces at a deterministic CDN path; temp URLs never leak into published content.

API module#

typescriptimport { generateBrandedImage, uploadToCdn, getCdnUrl, getLogoUrl, listLogos, reframe, canvaImport, resolveCourseFolderId, CANVA_FOLDERS } from "../snappy-image/api.ts";

Or CLI:

bashnpx tsx ~/.claude/skills/snappy-image/api.ts upload /tmp/thumb.jpg snappy-images/youtube/2026-04-08-my-video.jpg
npx tsx ~/.claude/skills/snappy-image/api.ts generate "Agent Browser" /tmp/thumb.png --format thumbnail --text "FEWER TOKENS"
npx tsx ~/.claude/skills/snappy-image/api.ts url snappy-images/youtube/2026-04-08-my-video.jpg
npx tsx ~/.claude/skills/snappy-image/api.ts logo xano
npx tsx ~/.claude/skills/snappy-image/api.ts logos
npx tsx ~/.claude/skills/snappy-image/api.ts canva /tmp/img.png blog --topic "X" --title "Y"
npx tsx ~/.claude/skills/snappy-image/api.ts canva /tmp/img.png generated --course "Ray Content"
npx tsx ~/.claude/skills/snappy-image/api.ts course-folder "Agentic Development"
npx tsx ~/.claude/skills/snappy-image/api.ts reframe /tmp/src.png --platforms --out-dir /tmp/variants/

API functions#

Function Purpose
generateBrandedImage(options) Run the centralized brand-aware scripts/generate.sh pipeline through a typed API.
uploadToCdn(localPath, remotePath) Upload file to DO Spaces, returns public CDN URL
getCdnUrl(remotePath) Get public CDN URL for a remote path (no upload)
getLogoUrl(toolName) Look up canonical logo grounding-ref URL by tool name (e.g. "Xano", "claude"). Returns null if unknown. Keys and aliases in logos.json.
listLogos() Return sorted array of canonical logo slugs (excludes aliases)
reframe(sourcePath, opts?) PIL reframe to all platform variants (default) or single target. Returns array of output paths. opts: { platforms?: boolean, outDir?: string, out?: string, size?: string }
resolveCourseFolderId(name) Resolve (or create) a Canva subfolder under Course Images. Fuzzy-matches against known courses. Idempotent. Returns folder ID.
canvaImport(filePath, opts?) Import image as editable Canva design. Fire-and-forget by default. opts: { folder?, topic?, format?, title?, sync?, full? }. folder is a key from CANVA_FOLDERS or a raw folder ID.
CANVA_FOLDERS Exported const: generated, course, youtube, blog, linkedin, base-ref, brand, logos-flat, logos-ink

The one command to remember#

bashbash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --council <panel> --matrix \
  --topic "<what the lesson is about>" \
  --text "<title in lower third>" \
  --slug <short-slug> --out-dir /tmp/<slug> \
  --format linkedin-post

This single invocation: runs the art council with the chosen panel, emits all 4 expert proposals, fans out 4 parallel draft+inspect+auto-edit loops, and prints 4-8 CDN URLs. Pick the best one. ~90 seconds end-to-end.


Scripts (the operational interface)#

This skill is script-based. These are the primary interfaces:

generate.sh -- the main image pipeline#

bashbash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format linkedin-post \
  --illustration "a compass with its needle pointing to a star" \
  --text "Find Your Direction" \
  --logo claude-code \
  --out /tmp/lesson.png

Key flags:

  • --format -- output format (see format table below)
  • --illustration -- Layer 2 subject description
  • --text -- Layer 5 title text (composited by PIL, NOT rendered by Nano Banana)
  • --logo -- tool logo slug from logos.json (looked up via getLogoUrl)
  • --ref -- single reference image URL or path (grounding template)
  • --aspect -- aspect ratio override (1:1 | 9:16 | 16:9 | 3:4 | 4:3 | 21:9 | 4:5)
  • --reframe -- after generation, PIL-reframe to all platform variants
  • --no-canva -- skip automatic Canva upload (default: uploads)
  • --course "Name" -- route to course-specific Canva subfolder
  • --canva-full -- run full Canva pipeline (import + resize + export)
  • --out -- output path

What it does automatically:

  1. Reads data/prompt-learnings.md and injects active learnings as HARD RULES
  2. Builds the 6-layer prompt from the grounding template system
  3. Generates via Nano Banana 2 (snappy-gemini)
  4. Composites title typography via PIL (EB Garamond 500, #2a1f14)
  5. Normalizes aspect ratio via sips center-crop (fixes Gemini's ~30% aspect drift)
  6. Appends to data/generations.jsonl ledger
  7. Uploads to Canva (background, non-fatal)

Always set these envs before calling generate.sh:

bashexport SNAPPY_AGENT_ID="<your-agent-id>"
export SNAPPY_CALLER="<caller>"  # e.g. "course-cover", "blog-hero"

generate-iterate.sh -- self-inspection loop#

Runs draft-inspect-edit with actor!=auditor (Nano Banana 2 drafts, Gemini 3.1 Pro inspects).

bash# Known illustration, auto-polish
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --illustration "A cross-section of a mechanical clock..." \
  --text "Hours Saved" --slug hours --out-dir /tmp/hours \
  --format linkedin-post --max-auto-turns 1

# Topic only, explore framings (matrix fan-out)
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --council business --matrix \
  --topic "Ray's Four Drivers of Rate" \
  --text "The Four Drivers of Your Rate" \
  --slug rate --out-dir /tmp/rate \
  --format linkedin-post

# Post-hoc manual fix
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --resume /tmp/rate --edit "make the labels bigger"

Flags: --illustration, --text, --slug, --out-dir, --format, --aspect (threaded through matrix + edit passes), --max-auto-turns (default 1), --council <panel>, --matrix, --topic, --resume <dir>, --edit "...".

Known limitation: --resume does not increment the version counter on second --edit call; it reruns from v1 rather than creating v4.

council.sh -- art council (standalone)#

Returns concepts without rendering. Use when you want proposals to review before generating.

bash# Winner only (default)
bash ~/.claude/skills/snappy-image/scripts/council.sh \
  --topic "..." --format linkedin-post --panel textbook

# All proposals (for matrix fan-out or manual selection)
bash ~/.claude/skills/snappy-image/scripts/council.sh \
  --topic "..." --format linkedin-post --panel textbook --emit-all

cdn-upload.sh -- final upload#

bashbash ~/.claude/skills/snappy-image/scripts/cdn-upload.sh \
  --file /tmp/img.jpg --context youtube --slug video-title-slug

# Override full key path (replace existing asset in place)
bash ~/.claude/skills/snappy-image/scripts/cdn-upload.sh \
  --file /tmp/new.png --key banners/2026-04-11-snappy-linkedin.png

Use --key (NOT --remote) to override the path.

compose-ref.sh -- multi-ref composite#

generate.sh only accepts a single --ref. When you need both a style template AND a logo:

bashbash ~/.claude/skills/snappy-image/scripts/compose-ref.sh \
  --template <url-or-path> --logo <slug-or-url> --out /tmp/composed-ref.png

Warning: compose-ref.sh parks logos at 15% in the lower-right corner. For hero-element logos (banners, profile images), build your own PIL composite.

Other scripts#

Script Purpose
scripts/score-generation.sh Score a generation against the rubric (R1-R6)
scripts/refine.sh Targeted edit pass on an existing image
scripts/reframe.py PIL reframe to platform variants (also available via api.ts)
scripts/build-logo-registry.sh Rebuild logos.json from data/logos/
scripts/canva-sync.ts Canva design sync and pipeline orchestration

Provider picker#

Need Provider Command
Original styled image (Nano Banana 2) snappy-gemini npx tsx ~/.claude/skills/snappy-gemini/api.ts image "..." --out /tmp/img.png
Styled image with reference snappy-gemini npx tsx ~/.claude/skills/snappy-gemini/api.ts image "..." --ref /tmp/ref.png --out /tmp/img.png
Photo-realistic (Imagen 3/4) snappy-gemini bash ~/.claude/skills/snappy-gemini/scripts/image.sh --model imagen-3.0-generate-002 --prompt "..." --aspect 16:9 --count 4 --out /tmp/imagen
gpt-image-1 / dall-e-3 snappy-ai-models bash ~/.claude/skills/snappy-ai-models/openai/image.sh --model gpt-image-1 --prompt "..." --size 1024x1024 --out /tmp/img.png
Native macOS screenshot peekaboo peekaboo image --app "Name" --path /tmp/x.png
Full screen capture screencapture screencapture -x /tmp/screen.png
Web page screenshot agent-browser npx agent-browser screenshot /tmp/page.png

Rule of thumb: Nano Banana = stylized/fast, Imagen = photo-real, gpt-image-1 = text-rendering, Canva API = templated branding. Generate visuals WITHOUT text in AI; add text via composite or Canva.


Ink Journal visual identity#

Every Snappy illustration is Ink Journal: cream paper, black ink line drawings, clean serif typography. Bespoke, quiet, premium.

DNA#

  • Warm cream paper background (#F5F0E6) with subtle texture
  • Thin clean black square border inset from edges
  • Black ink line illustrations -- architectural, precise, fine-tip Micron pen quality
  • One object per image, one metaphor
  • No color. Black ink on cream paper only. No gray fills, no gradients
  • Generous whitespace inside the frame
  • EB Garamond 500 (#2a1f14) composited in the lower third

The sanity test (mandatory before ANY generation)#

"If someone sees only the picture -- no text -- do they get the concept?"

Plan the illustration BEFORE writing the prompt: (1) what is this about? (2) what visual metaphor communicates that? (3) does it pass the sanity test? If you can't describe a clear visual metaphor, use typography only.

Brand duality: Ink Journal vs editorial-flat#

Two systems exist, both cream-and-ink. Never mix in one composition.

  • Ink Journal -- hand-drawn line art, cream #F5F0E6, pure black ink, serif caption. For: content illustrations, blog diagrams, course covers.
  • Editorial-flat -- typographic, Inter sans, terracotta #c96442 accent, ink #3d3929. For: profile banners, hero assets, product surfaces, anywhere the brand stands alone.
  • Canonical editorial-flat spec lives in snappy-positioning AGENTS.md section 8. When the two disagree, snappy-positioning wins.

Exception#

YouTube thumbnails break from Ink Journal -- bold colors, Robert's face, high contrast to compete in the grid.


6-layer prompt system#

Every image prompt has 6 layers. Only layers 2 and 5 change per image.

Layer Purpose Content
1 Template Lock "Using the reference image as the exact background template -- preserve the identical cream paper texture, border thickness, border position, and overall feel."
2 Illustration Subject CHANGES -- "Add inside the frame, centered in the upper two-thirds: a beautiful delicate black ink line drawing of [SPECIFIC ILLUSTRATION]."
3 Material & Technique "The drawing has the quality of a fine-tip Micron pen on premium cotton paper -- precise architectural linework, consistent stroke weight, no sketchy crosshatching."
4 Composition & Space "The illustration occupies approximately 50-60% of the interior frame area, centered horizontally, positioned in the upper two-thirds."
5 Typography Slot CHANGES -- For course assets: generate TEXT-FREE, composite later via PIL. For other formats: title in lower third.
6 Constraints "No color of any kind. No gray fills. No gradients. No additional text, watermarks, or labels beyond the specified text. No elements outside the black border frame."

Composite typography (course assets and numbered series)#

Title text is composited by PIL code, never by Nano Banana. Nano silently rewrites punctuation (--> to to), drops em dashes, and sometimes all-caps mixed-case titles.

Property Value
Font EB Garamond 500 (Medium)
Color #2a1f14 (warm near-black)
Blur 0.6px gaussian (kills digital sharpness)
Base size ~48pt at 1080x1080, shrink to fit, never grow past 56pt
Alignment Center

Band geometry (defaults):

Aspect Band top Band bottom Band left Band right
1:1 (1080x1080) 70% (756px) 92% (994px) 10% (108px) 90% (972px)
3:4 (1080x1440) 72% (1037px) 93% (1339px) 10% (108px) 90% (972px)
16:9 (1920x1080) 72% (778px) 92% (994px) 12% (230px) 88% (1690px)

Hard rule: the composite script raises on overflow. It does NOT silently wrap or truncate. If the title doesn't fit, rewrite the title or widen the band.


Grounding assets (base refs)#

Format Aspect URL
Carousel 3:4 https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-1773733973000.png
LinkedIn post 1:1 https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-1773735051000.png
Video/blog hero 16:9 https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-1773735074000.png
LinkedIn banner 21:9 https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-inkjournal-21x9-base.png

These are lorem-ipsum templates: cream paper + border + placeholder + lorem ipsum at the exact title position. The prompt tells Gemini to REPLACE the lorem ipsum with the real title -- layout transfer, not position instruction. Works ~95% of the time.


Art council panels#

Panels live in data/panels/<name>.json. Adding a new panel is one JSON file, zero script changes.

Panel Best for
art Editorial illustration, blog heroes, LinkedIn posts
art-photography Portraits, photo-driven covers
textbook Explainers, diagrams, course lessons (Macaulay, Feynman, Tufte, Eames)
business Consulting economics, sales, Ray mentorship
whimsy Approachable lessons, beginner content
narrative Before/after case studies, protagonist-driven
data-journalism Metrics, rate math, retainer economics
technical-diagram Software architecture, system diagrams (Tufte, Victor, Tesler, Wardley)

Known issue: art-photography panel drifts to still-life objects on founder-portrait briefs. Skip the council for founder portraits; use pre-composed --ref with direct image.sh call.


Aspect ratios -- master table#

Platform Ratio Pixels Format
LinkedIn post 1:1 1080x1080 PNG
LinkedIn carousel 3:4 1080x1350 PDF (slides as images)
X / Twitter post 16:9 1600x900 JPG
Instagram feed 4:5 1080x1350 JPG
Instagram story / Reel cover 9:16 1080x1920 JPG
YouTube thumbnail 16:9 1280x720 JPG
Blog hero 16:9 1200x675 JPG
Blog OG image 1.91:1 1200x630 JPG
Skool post 1:1 1080x1080 JPG
Meta ad square 1:1 1080x1080 JPG
Meta ad vertical 9:16 1080x1920 JPG
YouTube ad 16:9 1920x1080 JPG
Favicon 1:1 32x32 PNG
Apple Touch Icon 1:1 180x180 PNG

CRITICAL -- sips pixel argument order is HEIGHT then WIDTH#

sips -z HEIGHT WIDTH file   # NOT width height!
sips -c HEIGHT WIDTH file   # crop is also HW

A 1280x720 YouTube thumbnail is sips -z 720 1280 -- NOT sips -z 1280 720.

Canonical sips one-liners#

bashsips -z 720 1280 /tmp/in.png                          # 1280x720 YouTube thumb
sips -z 1080 1080 /tmp/in.png                          # 1080x1080 square
sips -z 1350 1080 /tmp/in.png                          # 1080x1350 portrait
sips -z 675 1200 /tmp/in.png                           # 1200x675 blog hero
sips -z 630 1200 /tmp/in.png                           # 1200x630 OG image
sips -z 1920 1080 /tmp/in.png                          # 1080x1920 vertical
sips -z 900 1600 /tmp/in.png                           # 1600x900 X/Twitter
sips -z 32 32 /tmp/in.png                              # 32x32 favicon
sips -z 180 180 /tmp/in.png                            # 180x180 apple-touch-icon
sips --resampleWidth 1200 /tmp/in.png                  # resize by width, keep aspect
sips -c 600 800 /tmp/in.png                            # crop from center (HW)
sips -g pixelWidth -g pixelHeight /tmp/in.png          # current dimensions
sips -s formatOptions 85 -s format jpeg /tmp/in.png --out /tmp/out.jpg  # PNG to JPEG

Use sips only -- never ImageMagick convert on the Mac Mini. Never cwebp for LinkedIn/YouTube (webp not accepted everywhere).


CDN path convention#

https://robert-storage.tor1.digitaloceanspaces.com/{context}/{date}-{slug}.{ext}

The canonical bucket is robert-storage in tor1. The snappy-cdn.nyc3 host does not exist -- never use it.

Context Example
youtube .../youtube/2026-04-08-video-title-thumb.jpg
linkedin .../linkedin/2026-04-08-post-slug.png
carousels .../carousels/2026-04-08-carousel-name/slide-01.png
blog .../blog/2026-04-08-hero-image.jpg
skool .../skool/2026-04-08-post-slug.jpg
banners .../banners/2026-04-08-banner-slug.png
ads .../ads/2026-04-08-ad-name-1x1.jpg

Date is ISO YYYY-MM-DD. Slug is kebab-case.


PIL reframe -- one generation, every platform#

Generate once, then PIL reframes to all platform variants by extending the cream background and redrawing the border.

bash# Via generate.sh (generate + reframe in one shot)
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format linkedin-post --text "Close the Loop" --reframe --out /tmp/pid.png

# Standalone (existing image)
python3 ~/.claude/skills/snappy-image/scripts/reframe.py /tmp/source.png --platforms --out-dir /tmp/variants/
python3 ~/.claude/skills/snappy-image/scripts/reframe.py /tmp/source.png --out /tmp/custom.png --size 1920x1080
Variant Dimensions Use case
blog-16x9 1920x1080 Blog heroes, video slides
ig-4x5 1080x1350 Instagram feed, LinkedIn carousel
yt-thumb 1280x720 YouTube thumbnails
skool-1x1 1080x1080 Skool posts, LinkedIn posts

Orientation-aware: Wide sources (>1.2:1) only reframe to blog-16x9 + yt-thumb. Square/portrait sources get all 4 variants. Wide-to-portrait reframes break horizontal diagrams -- content shrinks to ~30% with massive empty space.


Canva hybrid pipeline (PREFERRED for branded output)#

Split the work: Gemini generates ONLY the illustration, Canva handles text + layout.

bash# 1. Generate illustration only (no text, no border, white background)
bash ~/.claude/skills/snappy-gemini/scripts/image.sh \
  --prompt "A delicate black ink line drawing of <concept>. White background, NO text, NO border." \
  --out /tmp/illustration.png

# 2. Remove white background (transparent PNG)
python3 -c "
from PIL import Image
img = Image.open('/tmp/illustration.png').convert('RGBA')
px = img.load()
for y in range(img.height):
    for x in range(img.width):
        r,g,b,a = px[x,y]
        if r > 230 and g > 230 and b > 230: px[x,y] = (r,g,b,0)
img.save('/tmp/illustration-t.png')
"

# 3. Upload + autofill + export via Canva API
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts upload /tmp/illustration-t.png
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts autofill EAHGnYIfdKQ \
  '{"title":{"type":"text","text":"The Title"},"subtitle":{"type":"text","text":"Subtitle"},"hero_image":{"type":"image","asset_id":"MAHGr6rhcmU"}}' \
  "Design Name"
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts export DAHGr7G-sCA png

Brand template EAHGnYIfdKQ: Ink Journal 1:1 with cream background, border, title/subtitle/hero_image fields.


Canva auto-sync#

Every generated image automatically uploads to Canva (default-on). Use --no-canva to skip.

Canva folder IDs:

Key Folder ID Purpose
generated FAHGryjpN3c Default
course FAHGsRxfKoU Course Images (parent -- subfolders per course)
youtube FAHGsUIkggM YouTube Thumbnails
blog FAHGsVN6qK4 Blog Heroes
linkedin FAHGsTUK1S0 LinkedIn Featured
base-ref FAHGr065Z-E Base Ref Templates
brand FAHGr-3HtNU Snappy brand
logos-flat FAHGszPSUR4 Official logos
logos-ink FAHGs7_GvDw Ink Journal logo variants

Logo grounding#

When a lesson mentions specific software, use the real logo as a grounding asset alongside the cream-paper ref.

Logos live at: https://robert-storage.tor1.digitaloceanspaces.com/logos/<slug>.png

Local mirror: ~/.claude/skills/snappy-image/data/logos/

Ink variants: data/logos-ink/{key}.png -- hand-drawn variants for Ink Journal compositions. Use flat for vector contexts, ink for Ink Journal. Keyed as {tool}_ink in logos.json.

Logo contrast check (REQUIRED)#

Before composing a logo onto cream paper, verify it is dark-on-light. Mean luminance of non-transparent pixels MUST be <=180/255. A white logo on cream is invisible.

Known offender: xano.png -- white wordmark, mean luminance ~225. Needs dark variant or runtime inversion.

Audit file: data/logos/contrast-audit.tsv. Regenerate when adding logos.

Logo prompt rules (from PID loop)#

  • Logo refs MUST be official flat PNGs (black on transparent, SVG-derived), never AI-generated ink variants. Ink variants compound drift.
  • Prompt must say: "reproduce the logo EXACTLY as it appears in the provided reference image, do not substitute any other icon or symbol regardless of the brand name's meaning."
  • Logo minimum size: must occupy at least 5-8% of total frame area. Add to prompt: "the logo must be clearly visible and occupy approximately 6% of the frame."

Multi-ref composite#

For brand-grounded generations needing both a style hint AND a faithful wordmark, pre-composite with Python/PIL: build canvas, paste faded style sample as background, paste logo at exact position/size, save RGB PNG, pass as --ref.

Snappy wordmark: https://robert-storage.tor1.digitaloceanspaces.com/logos/snappy.png (1024x512, black-on-transparent).


Course image templates (MANDATORY for Skool classroom images)#

Templates live in templates/. Every course image MUST use the template.

Template File Format flag Dimensions
Course cover (wide) templates/course-cover.md --format course-cover 1940x1000
Lesson cover (square) templates/course-lesson.md --format linkedin-post 1080x1080
bash# Lesson cover
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format linkedin-post --illustration "a compass with its needle pointing to a star" \
  --text "Find Your Direction" --logo claude-code --out /tmp/lesson.png

# Course cover
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format course-cover --illustration "a wide panoramic blueprint with 6 stations" \
  --text "Claude Code Mastery" --logo claude-code --out /tmp/cover.png

Required flags for ALL course images: --format, --illustration (Layer 2), --text (Layer 5), --logo (from frontmatter logo_ref).

Frontmatter contract: draft .md files MUST have multi-line image_prompt: with all 6 layers, plus logo_ref:, image_cdn_url:, image_generated_at:. Single-line image_prompt values are a system defect.

Course covers go at the TOP of the lesson body in Skool, not appended at the bottom.


Educational / structured formats#

Format Description
process-wide, process-square 3-step workflow with numbered circles + arrows
comparison-wide, comparison-square A vs B split with detail points
cycle-wide, cycle-square 4-node circular loop with arrows
hierarchy-wide, hierarchy-square Parent to 3 children tree
annotated-wide, annotated-square Center illustration with 4 callout labels

Usage: --format process-wide --illustration "Replace the dot in circle 1 with...". These use --illustration for multi-zone replacement, NOT --text.


LinkedIn banner safe zones (PERMANENT)#

LinkedIn overlays the circular avatar on the bottom-left. Never place content there.

  • Desktop danger zone: left 300px x bottom 150px of 1584x396 frame (~19% x 38%)
  • Mobile danger zone: bottom-left ~36% x ~67%
  • Mobile edge crop: outer ~15-20% per side is lost
  • Primary safe zone: horizontal 19%-82%, vertical 5%-75%
  • Push wordmark upper-center, tagline beneath or on the right

Rubric (R1-R6) -- score every generation#

ID Check Pass Fail
R1 aspect_correct Final dims match requested format exactly Any deviation (Gemini drifts ~30%)
R2 style_on_brand Cream paper, pure black ink, no color/gradients/gray Any color, gradient, gray wash, photo-realism
R3 subject_matches_metaphor Illustration IS the concept; viewer understands without text Wrong subject, fabricated subject
R4 title_legible Title renders exactly once, lower third, clean serif Missing, duplicated, gibberish, wrong position
R5 typography_placement Title lower third, illustration upper two-thirds, breathing room Overlap, wrong position
R6 no_extras Nothing outside frame, no watermarks/logos/signatures/labels Any unrequested element

Score via: bash scripts/score-generation.sh <out-path> <pass|partial|fail> "<failed-checks>" "<reasons>" "<proposed-delta>"


PID learning loop#

Every generation feeds the next:

  1. generate.sh reads data/prompt-learnings.md and injects active learnings into every prompt
  2. After generation, score against rubric (R1-R6)
  3. Recurring failures get promoted into prompt-learnings.md active learnings
  4. The same mistake gets caught once and prevented forever

Key active learnings (abbreviated -- full list in data/prompt-learnings.md):

  • Force "square 1:1 aspect ratio, equal width and height" for square formats
  • Title must render "exactly once, in the lower third, never duplicated"
  • "No tonal gradation, no 3D shading, no light-to-dark transitions -- every surface is pure black outlines with pure cream interior, like a coloring book page"
  • "Strictly flat 2D top-down or straight-on elevation view, NO isometric projection"
  • "NO text of any kind inside the drawing area -- no labels, no field names, no annotations"
  • Software topics need software metaphors, never literal buildings/observatories
  • Never depict agents as robots/humanoids; show as node graphs or box-and-arrow workflows
  • Container illustrations must be "empty or contain only abstract geometric marks, never letterforms"

Nugget-based image requests (content_atoms)#

When snappy-content or snappy-linkedin passes speaker, topic, and type:

  • tool-tutorial -- diagram of tool's workflow loop (1080x1080)
  • framework -- before/after or step-by-step visual (1080x1080)
  • architecture -- stack diagram showing components (1080x1080)
  • workflow -- carousel slides, one step per slide (1080x1350)
  • synthesis -- Venn diagram or multi-column comparison (1080x1080)

Generate visual without text. Copy overlays in Canva or edit pass.


Core workflows (compressed)#

YouTube thumbnail (1280x720 JPG)#

  1. Confirm title with snappy-youtube. 2. Generate base WITHOUT text via Nano Banana (16:9). 3. Add text in Canva or Grok edit pass. 4. sips -z 720 1280 then jpeg 85. 5. cdn-upload.sh --context youtube --slug <slug>.

LinkedIn post (1080x1080 PNG)#

  1. Copy approved by snappy-content. 2. Nano Banana at 1:1, no text. 3. sips -z 1080 1080. 4. cdn-upload.sh --context linkedin --slug <slug>.
  1. REQUIRE approved ASCII storyboard from snappy-content. 2. Generate slides at 4:5/3:4, no text. 3. Add copy in Canva. 4. Normalize: for f in /tmp/carousel/slide-*.png; do sips -z 1350 1080 "$f"; done. 5. Upload slides + assemble PDF: for f in slide-*.png; do sips -s format pdf "$f" --out "${f%.png}.pdf"; done && pdfunite slide-*.pdf carousel.pdf. 6. Upload PDF.

Blog hero (1200x675 JPG) + OG (1200x630 JPG)#

  1. Title from snappy-blog. 2. Nano Banana 16:9. 3. Hero: sips -z 675 1200 jpeg 85. 4. OG: sips -c 630 1200 hero.jpg --out og.jpg. 5. Upload both.

generate-iterate.sh --council --matrix does NOT accept --ref. For base-ref banners:

bash# 1. Get concepts from panel
bash ~/.claude/skills/snappy-image/scripts/council.sh \
  --panel art --topic "..." --format linkedin-banner --emit-all > /tmp/proposals.json

# 2. Fan out renders with base ref
jq -r '.proposals[] | @base64' /tmp/proposals.json | while read row; do
  FRAG=$(echo "$row" | base64 -d | jq -r '.prompt_fragment')
  EXPERT=$(echo "$row" | base64 -d | jq -r '.expert')
  bash ~/.claude/skills/snappy-gemini/scripts/image.sh \
    --prompt "$FRAG" --aspect 21:9 \
    --ref https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-inkjournal-21x9-base.png \
    --out /tmp/banner-${EXPERT,,}.png &
done; wait

Rules#

  • Copy not approved -- STOP. Route to snappy-content.
  • Carousel without storyboard -- STOP. Require ASCII storyboard.
  • DO Spaces creds missing -- STOP. Check snappy-settings/.env.cache.
  • Temp URL in published content -- REFUSE. All finals on CDN.
  • Wrong CDN path -- REFUSE. Fix path first.
  • sips HW order wrong -- STOP. Verify with sips -g pixelWidth -g pixelHeight.
  • Text inside AI image -- REFUSE. Generate visual, add text in composite/Canva.
  • ImageMagick convert / cwebp -- REFUSE. Use sips.
  • Relative path in MDX -- REFUSE. Full CDN URL only.
  • 4K for a draft -- STOP. 1K drafts, 2K finals, 4K print only.
  • Same image, no resize -- STOP. Resize per aspect table.

Uses#

Skill Relationship
snappy-content Upstream -- provides approved copy and storyboards
snappy-gemini Provider -- image.sh for Nano Banana 2 and Imagen
snappy-ai-models Provider -- openai/image.sh for gpt-image-1
snappy-browse Canva API (canva-api.ts, canva-import-design.ts, canva-pipeline.ts)
snappy-settings Credentials (DO_SPACES_KEY, DO_SPACES_SECRET)
snappy-positioning Visual brand spec (section 8), tone rules
snappy-youtube Consumes 1280x720 thumbnails
snappy-linkedin Consumes 1080x1080 images, carousel PDFs
snappy-blog / snappy-publish Consumes hero/OG CDN URLs
snappy-skool Consumes Skool images
snappy-course Consumes course cover/lesson images
snappy-ops Schedules image work in weekly batch

Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.

<!-- SKILL-INDEX-START -->

[snappy-image Index]|root: ~/.claude/skills/snappy-image|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,composite.md,course-cover-master-spec.md,optimization.md,prompting.md,providers.md,rubric.md,style.md,whiteboard.md,workflows.md}|data:{prompt-learnings.md}|templates:{course-cover.md,course-lesson.md}

<!-- SKILL-INDEX-END -->

Used by#

  • snappy-cleanshot
  • snappy-course
  • snappy-ffmpeg
  • snappy-remotion
  • snappy-sync

<!-- SNAPPY-CONTRACT-VERBS-START -->

Contract verbs#

Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.

Verb Contract arguments Effect First call
canva file, folder? write npx tsx ~/.claude/skills/snappy-image/api.ts canva <file>
course-folder course-name write npx tsx ~/.claude/skills/snappy-image/api.ts course-folder <course-name>
generate topic, out write-reversible npx tsx ~/.claude/skills/snappy-image/api.ts generate <topic> <out>
logo tool-name write npx tsx ~/.claude/skills/snappy-image/api.ts logo <tool-name>
logos read npx tsx ~/.claude/skills/snappy-image/api.ts logos
metrics metric-name read npx tsx ~/.claude/skills/snappy-image/api.ts metrics <metric-name>
reframe source write npx tsx ~/.claude/skills/snappy-image/api.ts reframe <source>
upload local-path, remote-path post npx tsx ~/.claude/skills/snappy-image/api.ts upload <local-path> <remote-path>
url remote-path read npx tsx ~/.claude/skills/snappy-image/api.ts url <remote-path>

Show the result#

When an answer carries face_hint, show it with one snappy_present(<answer>) call.

See /snappy-faces for face selection. Human-facing images must crop to the

element, render at 2x on Retina, and fill the destination channel instead of

placing a small card in a full-page screenshot.

<!-- SNAPPY-CONTRACT-VERBS-END -->

---
name: snappy-image
role: Centralized image producer -- generation, screenshots, resize/convert, CDN upload, Canva sync
loaded-by: PreToolUse hook (auto-injected when "snappy-image" is mentioned)
---

# snappy-image -- Agent Loader

You are the single producer of every image asset in the Snappy stack. You generate images via Nano Banana 2 (snappy-gemini), capture screenshots (peekaboo, agent-browser, screencapture), resize/convert via sips, composite typography via PIL, and upload finals to the DO Spaces CDN. Copy is always approved BEFORE images get generated -- if upstream copy is not locked, STOP and route to `snappy-content`. Every final asset lands on DO Spaces at a deterministic CDN path; temp URLs never leak into published content.

## API module

```typescript
import { generateBrandedImage, uploadToCdn, getCdnUrl, getLogoUrl, listLogos, reframe, canvaImport, resolveCourseFolderId, CANVA_FOLDERS } from "../snappy-image/api.ts";
```

Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-image/api.ts upload /tmp/thumb.jpg snappy-images/youtube/2026-04-08-my-video.jpg
npx tsx ~/.claude/skills/snappy-image/api.ts generate "Agent Browser" /tmp/thumb.png --format thumbnail --text "FEWER TOKENS"
npx tsx ~/.claude/skills/snappy-image/api.ts url snappy-images/youtube/2026-04-08-my-video.jpg
npx tsx ~/.claude/skills/snappy-image/api.ts logo xano
npx tsx ~/.claude/skills/snappy-image/api.ts logos
npx tsx ~/.claude/skills/snappy-image/api.ts canva /tmp/img.png blog --topic "X" --title "Y"
npx tsx ~/.claude/skills/snappy-image/api.ts canva /tmp/img.png generated --course "Ray Content"
npx tsx ~/.claude/skills/snappy-image/api.ts course-folder "Agentic Development"
npx tsx ~/.claude/skills/snappy-image/api.ts reframe /tmp/src.png --platforms --out-dir /tmp/variants/
```

## API functions

| Function | Purpose |
|----------|---------|
| `generateBrandedImage(options)` | Run the centralized brand-aware `scripts/generate.sh` pipeline through a typed API. |
| `uploadToCdn(localPath, remotePath)` | Upload file to DO Spaces, returns public CDN URL |
| `getCdnUrl(remotePath)` | Get public CDN URL for a remote path (no upload) |
| `getLogoUrl(toolName)` | Look up canonical logo grounding-ref URL by tool name (e.g. `"Xano"`, `"claude"`). Returns `null` if unknown. Keys and aliases in `logos.json`. |
| `listLogos()` | Return sorted array of canonical logo slugs (excludes aliases) |
| `reframe(sourcePath, opts?)` | PIL reframe to all platform variants (default) or single target. Returns array of output paths. `opts: { platforms?: boolean, outDir?: string, out?: string, size?: string }` |
| `resolveCourseFolderId(name)` | Resolve (or create) a Canva subfolder under Course Images. Fuzzy-matches against known courses. Idempotent. Returns folder ID. |
| `canvaImport(filePath, opts?)` | Import image as editable Canva design. Fire-and-forget by default. `opts: { folder?, topic?, format?, title?, sync?, full? }`. `folder` is a key from `CANVA_FOLDERS` or a raw folder ID. |
| `CANVA_FOLDERS` | Exported const: `generated`, `course`, `youtube`, `blog`, `linkedin`, `base-ref`, `brand`, `logos-flat`, `logos-ink` |

---

## The one command to remember

```bash
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --council <panel> --matrix \
  --topic "<what the lesson is about>" \
  --text "<title in lower third>" \
  --slug <short-slug> --out-dir /tmp/<slug> \
  --format linkedin-post
```

This single invocation: runs the art council with the chosen panel, emits all 4 expert proposals, fans out 4 parallel draft+inspect+auto-edit loops, and prints 4-8 CDN URLs. Pick the best one. ~90 seconds end-to-end.

---

## Scripts (the operational interface)

This skill is script-based. These are the primary interfaces:

### generate.sh -- the main image pipeline

```bash
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format linkedin-post \
  --illustration "a compass with its needle pointing to a star" \
  --text "Find Your Direction" \
  --logo claude-code \
  --out /tmp/lesson.png
```

**Key flags:**
- `--format` -- output format (see format table below)
- `--illustration` -- Layer 2 subject description
- `--text` -- Layer 5 title text (composited by PIL, NOT rendered by Nano Banana)
- `--logo` -- tool logo slug from `logos.json` (looked up via `getLogoUrl`)
- `--ref` -- single reference image URL or path (grounding template)
- `--aspect` -- aspect ratio override (`1:1 | 9:16 | 16:9 | 3:4 | 4:3 | 21:9 | 4:5`)
- `--reframe` -- after generation, PIL-reframe to all platform variants
- `--no-canva` -- skip automatic Canva upload (default: uploads)
- `--course "Name"` -- route to course-specific Canva subfolder
- `--canva-full` -- run full Canva pipeline (import + resize + export)
- `--out` -- output path

**What it does automatically:**
1. Reads `data/prompt-learnings.md` and injects active learnings as HARD RULES
2. Builds the 6-layer prompt from the grounding template system
3. Generates via Nano Banana 2 (snappy-gemini)
4. Composites title typography via PIL (EB Garamond 500, `#2a1f14`)
5. Normalizes aspect ratio via sips center-crop (fixes Gemini's ~30% aspect drift)
6. Appends to `data/generations.jsonl` ledger
7. Uploads to Canva (background, non-fatal)

**Always set these envs before calling generate.sh:**
```bash
export SNAPPY_AGENT_ID="<your-agent-id>"
export SNAPPY_CALLER="<caller>"  # e.g. "course-cover", "blog-hero"
```

### generate-iterate.sh -- self-inspection loop

Runs draft-inspect-edit with actor!=auditor (Nano Banana 2 drafts, Gemini 3.1 Pro inspects).

```bash
# Known illustration, auto-polish
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --illustration "A cross-section of a mechanical clock..." \
  --text "Hours Saved" --slug hours --out-dir /tmp/hours \
  --format linkedin-post --max-auto-turns 1

# Topic only, explore framings (matrix fan-out)
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --council business --matrix \
  --topic "Ray's Four Drivers of Rate" \
  --text "The Four Drivers of Your Rate" \
  --slug rate --out-dir /tmp/rate \
  --format linkedin-post

# Post-hoc manual fix
bash ~/.claude/skills/snappy-image/scripts/generate-iterate.sh \
  --resume /tmp/rate --edit "make the labels bigger"
```

**Flags:** `--illustration`, `--text`, `--slug`, `--out-dir`, `--format`, `--aspect` (threaded through matrix + edit passes), `--max-auto-turns` (default 1), `--council <panel>`, `--matrix`, `--topic`, `--resume <dir>`, `--edit "..."`.

**Known limitation:** `--resume` does not increment the version counter on second `--edit` call; it reruns from v1 rather than creating v4.

### council.sh -- art council (standalone)

Returns concepts without rendering. Use when you want proposals to review before generating.

```bash
# Winner only (default)
bash ~/.claude/skills/snappy-image/scripts/council.sh \
  --topic "..." --format linkedin-post --panel textbook

# All proposals (for matrix fan-out or manual selection)
bash ~/.claude/skills/snappy-image/scripts/council.sh \
  --topic "..." --format linkedin-post --panel textbook --emit-all
```

### cdn-upload.sh -- final upload

```bash
bash ~/.claude/skills/snappy-image/scripts/cdn-upload.sh \
  --file /tmp/img.jpg --context youtube --slug video-title-slug

# Override full key path (replace existing asset in place)
bash ~/.claude/skills/snappy-image/scripts/cdn-upload.sh \
  --file /tmp/new.png --key banners/2026-04-11-snappy-linkedin.png
```

Use `--key` (NOT `--remote`) to override the path.

### compose-ref.sh -- multi-ref composite

`generate.sh` only accepts a single `--ref`. When you need both a style template AND a logo:

```bash
bash ~/.claude/skills/snappy-image/scripts/compose-ref.sh \
  --template <url-or-path> --logo <slug-or-url> --out /tmp/composed-ref.png
```

**Warning:** `compose-ref.sh` parks logos at 15% in the lower-right corner. For hero-element logos (banners, profile images), build your own PIL composite.

### Other scripts

| Script | Purpose |
|--------|---------|
| `scripts/score-generation.sh` | Score a generation against the rubric (R1-R6) |
| `scripts/refine.sh` | Targeted edit pass on an existing image |
| `scripts/reframe.py` | PIL reframe to platform variants (also available via api.ts) |
| `scripts/build-logo-registry.sh` | Rebuild logos.json from data/logos/ |
| `scripts/canva-sync.ts` | Canva design sync and pipeline orchestration |

---

## Provider picker

| Need | Provider | Command |
|---|---|---|
| Original styled image (Nano Banana 2) | `snappy-gemini` | `npx tsx ~/.claude/skills/snappy-gemini/api.ts image "..." --out /tmp/img.png` |
| Styled image with reference | `snappy-gemini` | `npx tsx ~/.claude/skills/snappy-gemini/api.ts image "..." --ref /tmp/ref.png --out /tmp/img.png` |
| Photo-realistic (Imagen 3/4) | `snappy-gemini` | `bash ~/.claude/skills/snappy-gemini/scripts/image.sh --model imagen-3.0-generate-002 --prompt "..." --aspect 16:9 --count 4 --out /tmp/imagen` |
| gpt-image-1 / dall-e-3 | `snappy-ai-models` | `bash ~/.claude/skills/snappy-ai-models/openai/image.sh --model gpt-image-1 --prompt "..." --size 1024x1024 --out /tmp/img.png` |
| Native macOS screenshot | peekaboo | `peekaboo image --app "Name" --path /tmp/x.png` |
| Full screen capture | screencapture | `screencapture -x /tmp/screen.png` |
| Web page screenshot | agent-browser | `npx agent-browser screenshot /tmp/page.png` |

**Rule of thumb:** Nano Banana = stylized/fast, Imagen = photo-real, gpt-image-1 = text-rendering, Canva API = templated branding. Generate visuals WITHOUT text in AI; add text via composite or Canva.

---

## Ink Journal visual identity

Every Snappy illustration is Ink Journal: cream paper, black ink line drawings, clean serif typography. Bespoke, quiet, premium.

### DNA
- Warm cream paper background (`#F5F0E6`) with subtle texture
- Thin clean black square border inset from edges
- Black ink line illustrations -- architectural, precise, fine-tip Micron pen quality
- One object per image, one metaphor
- No color. Black ink on cream paper only. No gray fills, no gradients
- Generous whitespace inside the frame
- EB Garamond 500 (`#2a1f14`) composited in the lower third

### The sanity test (mandatory before ANY generation)
"If someone sees only the picture -- no text -- do they get the concept?"
Plan the illustration BEFORE writing the prompt: (1) what is this about? (2) what visual metaphor communicates that? (3) does it pass the sanity test? If you can't describe a clear visual metaphor, use typography only.

### Brand duality: Ink Journal vs editorial-flat
Two systems exist, both cream-and-ink. Never mix in one composition.
- **Ink Journal** -- hand-drawn line art, cream `#F5F0E6`, pure black ink, serif caption. For: content illustrations, blog diagrams, course covers.
- **Editorial-flat** -- typographic, Inter sans, terracotta `#c96442` accent, ink `#3d3929`. For: profile banners, hero assets, product surfaces, anywhere the brand stands alone.
- Canonical editorial-flat spec lives in `snappy-positioning` AGENTS.md section 8. When the two disagree, snappy-positioning wins.

### Exception
YouTube thumbnails break from Ink Journal -- bold colors, Robert's face, high contrast to compete in the grid.

---

## 6-layer prompt system

Every image prompt has 6 layers. Only layers 2 and 5 change per image.

| Layer | Purpose | Content |
|---|---|---|
| 1 | Template Lock | "Using the reference image as the exact background template -- preserve the identical cream paper texture, border thickness, border position, and overall feel." |
| 2 | Illustration Subject | **CHANGES** -- "Add inside the frame, centered in the upper two-thirds: a beautiful delicate black ink line drawing of [SPECIFIC ILLUSTRATION]." |
| 3 | Material & Technique | "The drawing has the quality of a fine-tip Micron pen on premium cotton paper -- precise architectural linework, consistent stroke weight, no sketchy crosshatching." |
| 4 | Composition & Space | "The illustration occupies approximately 50-60% of the interior frame area, centered horizontally, positioned in the upper two-thirds." |
| 5 | Typography Slot | **CHANGES** -- For course assets: generate TEXT-FREE, composite later via PIL. For other formats: title in lower third. |
| 6 | Constraints | "No color of any kind. No gray fills. No gradients. No additional text, watermarks, or labels beyond the specified text. No elements outside the black border frame." |

### Composite typography (course assets and numbered series)

Title text is composited by PIL code, never by Nano Banana. Nano silently rewrites punctuation (`-->` to `to`), drops em dashes, and sometimes all-caps mixed-case titles.

| Property | Value |
|---|---|
| Font | EB Garamond 500 (Medium) |
| Color | `#2a1f14` (warm near-black) |
| Blur | 0.6px gaussian (kills digital sharpness) |
| Base size | ~48pt at 1080x1080, shrink to fit, never grow past 56pt |
| Alignment | Center |

**Band geometry (defaults):**

| Aspect | Band top | Band bottom | Band left | Band right |
|---|---|---|---|---|
| 1:1 (1080x1080) | 70% (756px) | 92% (994px) | 10% (108px) | 90% (972px) |
| 3:4 (1080x1440) | 72% (1037px) | 93% (1339px) | 10% (108px) | 90% (972px) |
| 16:9 (1920x1080) | 72% (778px) | 92% (994px) | 12% (230px) | 88% (1690px) |

**Hard rule:** the composite script raises on overflow. It does NOT silently wrap or truncate. If the title doesn't fit, rewrite the title or widen the band.

---

## Grounding assets (base refs)

| Format | Aspect | URL |
|--------|--------|-----|
| Carousel | 3:4 | `https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-1773733973000.png` |
| LinkedIn post | 1:1 | `https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-1773735051000.png` |
| Video/blog hero | 16:9 | `https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-1773735074000.png` |
| LinkedIn banner | 21:9 | `https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-inkjournal-21x9-base.png` |

These are lorem-ipsum templates: cream paper + border + placeholder + lorem ipsum at the exact title position. The prompt tells Gemini to REPLACE the lorem ipsum with the real title -- layout transfer, not position instruction. Works ~95% of the time.

---

## Art council panels

Panels live in `data/panels/<name>.json`. Adding a new panel is one JSON file, zero script changes.

| Panel | Best for |
|---|---|
| `art` | Editorial illustration, blog heroes, LinkedIn posts |
| `art-photography` | Portraits, photo-driven covers |
| `textbook` | Explainers, diagrams, course lessons (Macaulay, Feynman, Tufte, Eames) |
| `business` | Consulting economics, sales, Ray mentorship |
| `whimsy` | Approachable lessons, beginner content |
| `narrative` | Before/after case studies, protagonist-driven |
| `data-journalism` | Metrics, rate math, retainer economics |
| `technical-diagram` | Software architecture, system diagrams (Tufte, Victor, Tesler, Wardley) |

**Known issue:** `art-photography` panel drifts to still-life objects on founder-portrait briefs. Skip the council for founder portraits; use pre-composed `--ref` with direct `image.sh` call.

---

## Aspect ratios -- master table

| Platform | Ratio | Pixels | Format |
|---|---|---|---|
| LinkedIn post | 1:1 | 1080x1080 | PNG |
| LinkedIn carousel | 3:4 | 1080x1350 | PDF (slides as images) |
| X / Twitter post | 16:9 | 1600x900 | JPG |
| Instagram feed | 4:5 | 1080x1350 | JPG |
| Instagram story / Reel cover | 9:16 | 1080x1920 | JPG |
| YouTube thumbnail | 16:9 | 1280x720 | JPG |
| Blog hero | 16:9 | 1200x675 | JPG |
| Blog OG image | 1.91:1 | 1200x630 | JPG |
| Skool post | 1:1 | 1080x1080 | JPG |
| Meta ad square | 1:1 | 1080x1080 | JPG |
| Meta ad vertical | 9:16 | 1080x1920 | JPG |
| YouTube ad | 16:9 | 1920x1080 | JPG |
| Favicon | 1:1 | 32x32 | PNG |
| Apple Touch Icon | 1:1 | 180x180 | PNG |

---

## CRITICAL -- sips pixel argument order is HEIGHT then WIDTH

```
sips -z HEIGHT WIDTH file   # NOT width height!
sips -c HEIGHT WIDTH file   # crop is also HW
```

A 1280x720 YouTube thumbnail is `sips -z 720 1280` -- NOT `sips -z 1280 720`.

### Canonical sips one-liners

```bash
sips -z 720 1280 /tmp/in.png                          # 1280x720 YouTube thumb
sips -z 1080 1080 /tmp/in.png                          # 1080x1080 square
sips -z 1350 1080 /tmp/in.png                          # 1080x1350 portrait
sips -z 675 1200 /tmp/in.png                           # 1200x675 blog hero
sips -z 630 1200 /tmp/in.png                           # 1200x630 OG image
sips -z 1920 1080 /tmp/in.png                          # 1080x1920 vertical
sips -z 900 1600 /tmp/in.png                           # 1600x900 X/Twitter
sips -z 32 32 /tmp/in.png                              # 32x32 favicon
sips -z 180 180 /tmp/in.png                            # 180x180 apple-touch-icon
sips --resampleWidth 1200 /tmp/in.png                  # resize by width, keep aspect
sips -c 600 800 /tmp/in.png                            # crop from center (HW)
sips -g pixelWidth -g pixelHeight /tmp/in.png          # current dimensions
sips -s formatOptions 85 -s format jpeg /tmp/in.png --out /tmp/out.jpg  # PNG to JPEG
```

Use `sips` only -- never ImageMagick `convert` on the Mac Mini. Never `cwebp` for LinkedIn/YouTube (webp not accepted everywhere).

---

## CDN path convention

```
https://robert-storage.tor1.digitaloceanspaces.com/{context}/{date}-{slug}.{ext}
```

The canonical bucket is `robert-storage` in `tor1`. **The `snappy-cdn.nyc3` host does not exist -- never use it.**

| Context | Example |
|---|---|
| `youtube` | `.../youtube/2026-04-08-video-title-thumb.jpg` |
| `linkedin` | `.../linkedin/2026-04-08-post-slug.png` |
| `carousels` | `.../carousels/2026-04-08-carousel-name/slide-01.png` |
| `blog` | `.../blog/2026-04-08-hero-image.jpg` |
| `skool` | `.../skool/2026-04-08-post-slug.jpg` |
| `banners` | `.../banners/2026-04-08-banner-slug.png` |
| `ads` | `.../ads/2026-04-08-ad-name-1x1.jpg` |

Date is ISO `YYYY-MM-DD`. Slug is kebab-case.

---

## PIL reframe -- one generation, every platform

Generate once, then PIL reframes to all platform variants by extending the cream background and redrawing the border.

```bash
# Via generate.sh (generate + reframe in one shot)
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format linkedin-post --text "Close the Loop" --reframe --out /tmp/pid.png

# Standalone (existing image)
python3 ~/.claude/skills/snappy-image/scripts/reframe.py /tmp/source.png --platforms --out-dir /tmp/variants/
python3 ~/.claude/skills/snappy-image/scripts/reframe.py /tmp/source.png --out /tmp/custom.png --size 1920x1080
```

| Variant | Dimensions | Use case |
|---------|-----------|----------|
| `blog-16x9` | 1920x1080 | Blog heroes, video slides |
| `ig-4x5` | 1080x1350 | Instagram feed, LinkedIn carousel |
| `yt-thumb` | 1280x720 | YouTube thumbnails |
| `skool-1x1` | 1080x1080 | Skool posts, LinkedIn posts |

**Orientation-aware:** Wide sources (>1.2:1) only reframe to blog-16x9 + yt-thumb. Square/portrait sources get all 4 variants. Wide-to-portrait reframes break horizontal diagrams -- content shrinks to ~30% with massive empty space.

---

## Canva hybrid pipeline (PREFERRED for branded output)

Split the work: Gemini generates ONLY the illustration, Canva handles text + layout.

```bash
# 1. Generate illustration only (no text, no border, white background)
bash ~/.claude/skills/snappy-gemini/scripts/image.sh \
  --prompt "A delicate black ink line drawing of <concept>. White background, NO text, NO border." \
  --out /tmp/illustration.png

# 2. Remove white background (transparent PNG)
python3 -c "
from PIL import Image
img = Image.open('/tmp/illustration.png').convert('RGBA')
px = img.load()
for y in range(img.height):
    for x in range(img.width):
        r,g,b,a = px[x,y]
        if r > 230 and g > 230 and b > 230: px[x,y] = (r,g,b,0)
img.save('/tmp/illustration-t.png')
"

# 3. Upload + autofill + export via Canva API
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts upload /tmp/illustration-t.png
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts autofill EAHGnYIfdKQ \
  '{"title":{"type":"text","text":"The Title"},"subtitle":{"type":"text","text":"Subtitle"},"hero_image":{"type":"image","asset_id":"MAHGr6rhcmU"}}' \
  "Design Name"
npx tsx ~/.claude/skills/snappy-browse/canva-api.ts export DAHGr7G-sCA png
```

Brand template `EAHGnYIfdKQ`: Ink Journal 1:1 with cream background, border, title/subtitle/hero_image fields.

---

## Canva auto-sync

Every generated image automatically uploads to Canva (default-on). Use `--no-canva` to skip.

**Canva folder IDs:**
| Key | Folder ID | Purpose |
|-----|-----------|---------|
| `generated` | `FAHGryjpN3c` | Default |
| `course` | `FAHGsRxfKoU` | Course Images (parent -- subfolders per course) |
| `youtube` | `FAHGsUIkggM` | YouTube Thumbnails |
| `blog` | `FAHGsVN6qK4` | Blog Heroes |
| `linkedin` | `FAHGsTUK1S0` | LinkedIn Featured |
| `base-ref` | `FAHGr065Z-E` | Base Ref Templates |
| `brand` | `FAHGr-3HtNU` | Snappy brand |
| `logos-flat` | `FAHGszPSUR4` | Official logos |
| `logos-ink` | `FAHGs7_GvDw` | Ink Journal logo variants |

---

## Logo grounding

When a lesson mentions specific software, use the real logo as a grounding asset alongside the cream-paper ref.

**Logos live at:** `https://robert-storage.tor1.digitaloceanspaces.com/logos/<slug>.png`
**Local mirror:** `~/.claude/skills/snappy-image/data/logos/`
**Ink variants:** `data/logos-ink/{key}.png` -- hand-drawn variants for Ink Journal compositions. Use flat for vector contexts, ink for Ink Journal. Keyed as `{tool}_ink` in `logos.json`.

### Logo contrast check (REQUIRED)

Before composing a logo onto cream paper, verify it is dark-on-light. Mean luminance of non-transparent pixels MUST be <=180/255. A white logo on cream is invisible.

**Known offender:** `xano.png` -- white wordmark, mean luminance ~225. Needs dark variant or runtime inversion.
**Audit file:** `data/logos/contrast-audit.tsv`. Regenerate when adding logos.

### Logo prompt rules (from PID loop)

- Logo refs MUST be official flat PNGs (black on transparent, SVG-derived), never AI-generated ink variants. Ink variants compound drift.
- Prompt must say: "reproduce the logo EXACTLY as it appears in the provided reference image, do not substitute any other icon or symbol regardless of the brand name's meaning."
- Logo minimum size: must occupy at least 5-8% of total frame area. Add to prompt: "the logo must be clearly visible and occupy approximately 6% of the frame."

### Multi-ref composite

For brand-grounded generations needing both a style hint AND a faithful wordmark, pre-composite with Python/PIL: build canvas, paste faded style sample as background, paste logo at exact position/size, save RGB PNG, pass as `--ref`.

**Snappy wordmark:** `https://robert-storage.tor1.digitaloceanspaces.com/logos/snappy.png` (1024x512, black-on-transparent).

---

## Course image templates (MANDATORY for Skool classroom images)

Templates live in `templates/`. Every course image MUST use the template.

| Template | File | Format flag | Dimensions |
|----------|------|-------------|------------|
| Course cover (wide) | `templates/course-cover.md` | `--format course-cover` | 1940x1000 |
| Lesson cover (square) | `templates/course-lesson.md` | `--format linkedin-post` | 1080x1080 |

```bash
# Lesson cover
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format linkedin-post --illustration "a compass with its needle pointing to a star" \
  --text "Find Your Direction" --logo claude-code --out /tmp/lesson.png

# Course cover
bash ~/.claude/skills/snappy-image/scripts/generate.sh \
  --format course-cover --illustration "a wide panoramic blueprint with 6 stations" \
  --text "Claude Code Mastery" --logo claude-code --out /tmp/cover.png
```

**Required flags for ALL course images:** `--format`, `--illustration` (Layer 2), `--text` (Layer 5), `--logo` (from frontmatter `logo_ref`).

**Frontmatter contract:** draft .md files MUST have multi-line `image_prompt:` with all 6 layers, plus `logo_ref:`, `image_cdn_url:`, `image_generated_at:`. Single-line `image_prompt` values are a system defect.

**Course covers go at the TOP of the lesson body** in Skool, not appended at the bottom.

---

## Educational / structured formats

| Format | Description |
|--------|-------------|
| `process-wide`, `process-square` | 3-step workflow with numbered circles + arrows |
| `comparison-wide`, `comparison-square` | A vs B split with detail points |
| `cycle-wide`, `cycle-square` | 4-node circular loop with arrows |
| `hierarchy-wide`, `hierarchy-square` | Parent to 3 children tree |
| `annotated-wide`, `annotated-square` | Center illustration with 4 callout labels |

Usage: `--format process-wide --illustration "Replace the dot in circle 1 with..."`. These use `--illustration` for multi-zone replacement, NOT `--text`.

---

## LinkedIn banner safe zones (PERMANENT)

LinkedIn overlays the circular avatar on the bottom-left. Never place content there.

- **Desktop danger zone:** left 300px x bottom 150px of 1584x396 frame (~19% x 38%)
- **Mobile danger zone:** bottom-left ~36% x ~67%
- **Mobile edge crop:** outer ~15-20% per side is lost
- **Primary safe zone:** horizontal 19%-82%, vertical 5%-75%
- Push wordmark upper-center, tagline beneath or on the right

---

## Rubric (R1-R6) -- score every generation

| ID | Check | Pass | Fail |
|---|---|---|---|
| R1 | aspect_correct | Final dims match requested format exactly | Any deviation (Gemini drifts ~30%) |
| R2 | style_on_brand | Cream paper, pure black ink, no color/gradients/gray | Any color, gradient, gray wash, photo-realism |
| R3 | subject_matches_metaphor | Illustration IS the concept; viewer understands without text | Wrong subject, fabricated subject |
| R4 | title_legible | Title renders exactly once, lower third, clean serif | Missing, duplicated, gibberish, wrong position |
| R5 | typography_placement | Title lower third, illustration upper two-thirds, breathing room | Overlap, wrong position |
| R6 | no_extras | Nothing outside frame, no watermarks/logos/signatures/labels | Any unrequested element |

Score via: `bash scripts/score-generation.sh <out-path> <pass|partial|fail> "<failed-checks>" "<reasons>" "<proposed-delta>"`

---

## PID learning loop

Every generation feeds the next:
1. `generate.sh` reads `data/prompt-learnings.md` and injects active learnings into every prompt
2. After generation, score against rubric (R1-R6)
3. Recurring failures get promoted into `prompt-learnings.md` active learnings
4. The same mistake gets caught once and prevented forever

**Key active learnings (abbreviated -- full list in `data/prompt-learnings.md`):**
- Force "square 1:1 aspect ratio, equal width and height" for square formats
- Title must render "exactly once, in the lower third, never duplicated"
- "No tonal gradation, no 3D shading, no light-to-dark transitions -- every surface is pure black outlines with pure cream interior, like a coloring book page"
- "Strictly flat 2D top-down or straight-on elevation view, NO isometric projection"
- "NO text of any kind inside the drawing area -- no labels, no field names, no annotations"
- Software topics need software metaphors, never literal buildings/observatories
- Never depict agents as robots/humanoids; show as node graphs or box-and-arrow workflows
- Container illustrations must be "empty or contain only abstract geometric marks, never letterforms"

---

## Nugget-based image requests (content_atoms)

When `snappy-content` or `snappy-linkedin` passes `speaker`, `topic`, and `type`:
- `tool-tutorial` -- diagram of tool's workflow loop (1080x1080)
- `framework` -- before/after or step-by-step visual (1080x1080)
- `architecture` -- stack diagram showing components (1080x1080)
- `workflow` -- carousel slides, one step per slide (1080x1350)
- `synthesis` -- Venn diagram or multi-column comparison (1080x1080)

Generate visual without text. Copy overlays in Canva or edit pass.

---

## Core workflows (compressed)

### YouTube thumbnail (1280x720 JPG)
1. Confirm title with `snappy-youtube`. 2. Generate base WITHOUT text via Nano Banana (16:9). 3. Add text in Canva or Grok edit pass. 4. `sips -z 720 1280` then jpeg 85. 5. `cdn-upload.sh --context youtube --slug <slug>`.

### LinkedIn post (1080x1080 PNG)
1. Copy approved by `snappy-content`. 2. Nano Banana at 1:1, no text. 3. `sips -z 1080 1080`. 4. `cdn-upload.sh --context linkedin --slug <slug>`.

### LinkedIn carousel (1080x1350 slides to PDF)
1. REQUIRE approved ASCII storyboard from `snappy-content`. 2. Generate slides at 4:5/3:4, no text. 3. Add copy in Canva. 4. Normalize: `for f in /tmp/carousel/slide-*.png; do sips -z 1350 1080 "$f"; done`. 5. Upload slides + assemble PDF: `for f in slide-*.png; do sips -s format pdf "$f" --out "${f%.png}.pdf"; done && pdfunite slide-*.pdf carousel.pdf`. 6. Upload PDF.

### Blog hero (1200x675 JPG) + OG (1200x630 JPG)
1. Title from `snappy-blog`. 2. Nano Banana 16:9. 3. Hero: `sips -z 675 1200` jpeg 85. 4. OG: `sips -c 630 1200 hero.jpg --out og.jpg`. 5. Upload both.

---

## Banner workflow with base refs (21:9, 4:1)

`generate-iterate.sh --council --matrix` does NOT accept `--ref`. For base-ref banners:

```bash
# 1. Get concepts from panel
bash ~/.claude/skills/snappy-image/scripts/council.sh \
  --panel art --topic "..." --format linkedin-banner --emit-all > /tmp/proposals.json

# 2. Fan out renders with base ref
jq -r '.proposals[] | @base64' /tmp/proposals.json | while read row; do
  FRAG=$(echo "$row" | base64 -d | jq -r '.prompt_fragment')
  EXPERT=$(echo "$row" | base64 -d | jq -r '.expert')
  bash ~/.claude/skills/snappy-gemini/scripts/image.sh \
    --prompt "$FRAG" --aspect 21:9 \
    --ref https://robert-storage.tor1.digitaloceanspaces.com/images/generated/img-inkjournal-21x9-base.png \
    --out /tmp/banner-${EXPERT,,}.png &
done; wait
```

---

## Rules

- **Copy not approved** -- STOP. Route to `snappy-content`.
- **Carousel without storyboard** -- STOP. Require ASCII storyboard.
- **DO Spaces creds missing** -- STOP. Check `snappy-settings/.env.cache`.
- **Temp URL in published content** -- REFUSE. All finals on CDN.
- **Wrong CDN path** -- REFUSE. Fix path first.
- **sips HW order wrong** -- STOP. Verify with `sips -g pixelWidth -g pixelHeight`.
- **Text inside AI image** -- REFUSE. Generate visual, add text in composite/Canva.
- **ImageMagick convert / cwebp** -- REFUSE. Use sips.
- **Relative path in MDX** -- REFUSE. Full CDN URL only.
- **4K for a draft** -- STOP. 1K drafts, 2K finals, 4K print only.
- **Same image, no resize** -- STOP. Resize per aspect table.

---

## Uses

| Skill | Relationship |
|---|---|
| `snappy-content` | Upstream -- provides approved copy and storyboards |
| `snappy-gemini` | Provider -- image.sh for Nano Banana 2 and Imagen |
| `snappy-ai-models` | Provider -- openai/image.sh for gpt-image-1 |
| `snappy-browse` | Canva API (canva-api.ts, canva-import-design.ts, canva-pipeline.ts) |
| `snappy-settings` | Credentials (DO_SPACES_KEY, DO_SPACES_SECRET) |
| `snappy-positioning` | Visual brand spec (section 8), tone rules |
| `snappy-youtube` | Consumes 1280x720 thumbnails |
| `snappy-linkedin` | Consumes 1080x1080 images, carousel PDFs |
| `snappy-blog` / `snappy-publish` | Consumes hero/OG CDN URLs |
| `snappy-skool` | Consumes Skool images |
| `snappy-course` | Consumes course cover/lesson images |
| `snappy-ops` | Schedules image work in weekly batch |

---


Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.

<!-- SKILL-INDEX-START -->
[snappy-image Index]|root: ~/.claude/skills/snappy-image|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,composite.md,course-cover-master-spec.md,optimization.md,prompting.md,providers.md,rubric.md,style.md,whiteboard.md,workflows.md}|data:{prompt-learnings.md}|templates:{course-cover.md,course-lesson.md}
<!-- SKILL-INDEX-END -->

## Used by

- `snappy-cleanshot`
- `snappy-course`
- `snappy-ffmpeg`
- `snappy-remotion`
- `snappy-sync`

<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs

Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.

| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `canva` | `file`, `folder?` | `write` | `npx tsx ~/.claude/skills/snappy-image/api.ts canva <file>` |
| `course-folder` | `course-name` | `write` | `npx tsx ~/.claude/skills/snappy-image/api.ts course-folder <course-name>` |
| `generate` | `topic`, `out` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-image/api.ts generate <topic> <out>` |
| `logo` | `tool-name` | `write` | `npx tsx ~/.claude/skills/snappy-image/api.ts logo <tool-name>` |
| `logos` | — | `read` | `npx tsx ~/.claude/skills/snappy-image/api.ts logos` |
| `metrics` | `metric-name` | `read` | `npx tsx ~/.claude/skills/snappy-image/api.ts metrics <metric-name>` |
| `reframe` | `source` | `write` | `npx tsx ~/.claude/skills/snappy-image/api.ts reframe <source>` |
| `upload` | `local-path`, `remote-path` | `post` | `npx tsx ~/.claude/skills/snappy-image/api.ts upload <local-path> <remote-path>` |
| `url` | `remote-path` | `read` | `npx tsx ~/.claude/skills/snappy-image/api.ts url <remote-path>` |

## Show the result

When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->

Keyboard Shortcuts

Search in document⌘K
Focus search/
Previous file tab
Next file tab
Close overlayEsc
Show shortcuts?