snappy-image skill
canva file folder?writecourse-folder course-namewritegenerate topic outwrite-reversiblelogo tool-namewritelogosreadmetrics metric-namereadreframe sourcewriteupload local-path remote-pathposturl remote-pathread/api:PB9UH7b9/linkedin/post-image/api:PB9UH7b9/linkedin/post-image$ npx snappy-skills install snappy-image
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.
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/
| 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 |
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.
This skill is script-based. These are the primary interfaces:
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 pathWhat it does automatically:
data/prompt-learnings.md and injects active learnings as HARD RULES#2a1f14)data/generations.jsonl ledgerAlways set these envs before calling generate.sh:
bashexport SNAPPY_AGENT_ID="<your-agent-id>"
export SNAPPY_CALLER="<caller>" # e.g. "course-cover", "blog-hero"
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.
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
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.
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.
| 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 |
| 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.
Every Snappy illustration is Ink Journal: cream paper, black ink line drawings, clean serif typography. Bespoke, quiet, premium.
#F5F0E6) with subtle texture#2a1f14) composited in the lower third"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.
Two systems exist, both cream-and-ink. Never mix in one composition.
#F5F0E6, pure black ink, serif caption. For: content illustrations, blog diagrams, course covers.#c96442 accent, ink #3d3929. For: profile banners, hero assets, product surfaces, anywhere the brand stands alone.snappy-positioning AGENTS.md section 8. When the two disagree, snappy-positioning wins.YouTube thumbnails break from Ink Journal -- bold colors, Robert's face, high contrast to compete in the grid.
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." |
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.
| 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.
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.
| 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 |
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.
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).
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.
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.
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.
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 |
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.
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.
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).
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.
| 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 overlays the circular avatar on the bottom-left. Never place content there.
| 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>"
Every generation feeds the next:
generate.sh reads data/prompt-learnings.md and injects active learnings into every promptprompt-learnings.md active learningsKey active learnings (abbreviated -- full list in data/prompt-learnings.md):
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.
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>.snappy-content. 2. Nano Banana at 1:1, no text. 3. sips -z 1080 1080. 4. cdn-upload.sh --context linkedin --slug <slug>.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.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
snappy-content.snappy-settings/.env.cache.sips -g pixelWidth -g pixelHeight.| 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 -->
snappy-cleanshotsnappy-coursesnappy-ffmpegsnappy-remotionsnappy-sync<!-- SNAPPY-CONTRACT-VERBS-START -->
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> |
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 -->