banner-creator
PublicRepository: resciencelab/opc-skills
Low Risk
No security issues found
Skill manifest does not include a 'license' field. Specifying a license helps users understand usage terms.
Remediation Add 'license' field to SKILL.md frontmatter (e.g., MIT, Apache-2.0)
Description
Create banners using AI image generation. Discuss format/style, generate variations, iterate with user feedback, crop to target ratio. Use when user wants to create a banner, header, hero image, cover image, GitHub banner, Twitter header, or readme banner.
Skill Files
# Banner Creator Skill
Create professional banners through AI image generation with an iterative design process.
## Prerequisites
**Required API Keys (set in environment):**
- `GEMINI_API_KEY` - Get from [Google AI Studio](https://aistudio.google.com/apikey)
**Required Skills:**
- `nanobanana` - AI image generation (Gemini 3 Pro Image)
## File Output Location
All generated files should be saved to the `.skill-archive` directory:
```
.skill-archive/banner-creator/<yyyy-mm-dd-summaryname>/
```
**Example:**
```
.skill-archive/banner-creator/2026-01-19-opc-banner/
banner-01.png
banner-02.png
...
banner-03-cropped.png
preview.html
```
## Workflow
### Step 1: Discovery & Requirements
Before generating, gather requirements from user:
**Ask about:**
1. **Purpose** - Where will the banner be used?
- GitHub README
- Twitter/X header
- LinkedIn banner
- Website hero
- YouTube channel art
2. **Target ratio/size** - See [references/formats.md](./references/formats.md):
- `2:1` (1280x640) - GitHub README
- `3:1` (1500x500) - Twitter header
- `16:9` (1920x1080) - Website hero
3. **Style preference**:
- Match existing logo/brand?
- Pixel art / 8-bit retro
- Minimalist / flat design
- Gradient / modern
- Illustrated / artistic
4. **Content elements**:
- Brand name / project name?
- Tagline / slogan?
- Logo character to include?
5. **Color preferences**:
- Existing brand colors?
- Let AI decide?
**Wait for user confirmation before proceeding!**
### Step 2: Generate Banner Variations
Generate 20 banner variations using the `nanobanana` skill:
```bash
# Generate single banner
python3 <nanobanana_skill_dir>/scripts/generate.py "{style} banner for {brand}, {description}, {text elements}" \
--ratio 21:9 -o .skill-archive/banner-creator/<date-name>/banner-01.png
# Batch generate 20 banners
python3 <nanobanana_skill_dir>/scripts/batch_generate.py "{style} banner for {brand}, {description}, {text elements}" \
-n 20 --ratio 21:9 -d .skill-archive/banner-creator/<date-name> -p banner
```
**Guidelines:**
- Generate at `21:9` ratio (widest available), crop later to target
- Use batch_generate.py for multiple variations (includes auto-delay)
- Use sequential naming: `banner-01.png`, `banner-02.png`, etc.
**Image Editing (for incorporating existing logo):**
```bash
python3 <nanobanana_skill_dir>/scripts/generate.py "add {logo character} to the left side of the banner" \
-i /path/to/existing-logo.png --ratio 21:9 -o banner-with-logo.png
```
### Step 3: Create HTML Preview
Copy the preview template and open in browser:
```bash
cp <skill_dir>/templates/preview.html .skill-archive/banner-creator/<yyyy-mm-dd-summaryname>/preview.html
```
Then open in default browser:
```bash
open .skill-archive/banner-creator/<yyyy-mm-dd-summaryname>/preview.html
```
**IMPORTANT:** Update the HTML to include the correct number of banners generated.
### Step 4: Iterate with User
Ask user which banners they prefer:
- "Which banners do you like? (e.g., #3, #7, #15)"
- "What do you like about them?"
- "Any changes you'd want?"
Based on feedback:
1. Generate 10-20 more variations of favorite styles
2. Use naming: `banner-{original}-v{n}.png` (e.g., `banner-03-v1.png`)
3. Update HTML preview
4. Repeat until user selects final banner
### Step 5: Crop to Target Ratio
Once user approves a banner, crop to target size:
```bash
python3 <skill_dir>/scripts/crop_banner.py {input.png} {output.png} --ratio 2:1 --width 1280
```
**Common targets:**
- GitHub README: `--ratio 2:1 --width 1280` → 1280x640
- Twitter header: `--ratio 3:1 --width 1500` → 1500x500
- Website hero: `--ratio 16:9 --width 1920` → 1920x1080
### Step 6: Deliver Final Assets
Present final deliverables:
```
## Final Banner Assets
| File | Description | Size |
|------|-------------|------|
| banner-03.png | Original (21:9) | 2016x864 |
| banner-03-cropped.png | GitHub README (2:1) | 1280x640 |
All files saved to: `.skill-archive/banner-creator/<yyyy-mm-dd-summaryname>/`
Copy final banner to user's desired location.
```
## Quick Reference
### Common Prompt Patterns
**With Text:**
```
Wide banner for {brand}, {style} style, featuring "{text}" prominently displayed, {colors}, {scene/elements}
```
**With Character:**
```
Wide banner featuring {character description}, {style} style, {scene}, text "{brand name}" on {position}, {colors}
```
**Abstract/Gradient:**
```
Abstract {style} banner, {colors} gradient, geometric patterns, modern tech feel, text "{brand}" centered
```
**Scene-based:**
```
{Style} illustration banner, {scene description}, {character} in {action}, "{brand}" text overlay, {colors}
```
### Supported Aspect Ratios
Generate at widest ratio, then crop:
- `21:9` - Ultra-wide (recommended for generation)
- `16:9` - Wide
- `3:2` - Standard wide
## References
- [references/formats.md](./references/formats.md) - Common banner sizes by platform
- [examples/opc-banner-creation.md](./examples/opc-banner-creation.md) - Full example conversation
{
"name": "banner-creator",
"version": "1.0.0",
"description": "Create banners using AI image generation. Discuss format/style, generate variations, iterate with user feedback, crop to target ratio for GitHub, Twitter, LinkedIn, etc.",
"author": {
"name": "ReScienceLab"
},
"homepage": "https://github.com/ReScienceLab/opc-skills/tree/main/skills/banner-creator",
"repository": "https://github.com/ReScienceLab/opc-skills",
"license": "MIT",
"keywords": [
"banner",
"header",
"hero image",
"cover image",
"create banner",
"github banner",
"twitter header",
"readme banner"
],
"skills": [
"./SKILL.md"
],
"commands": [
"./scripts/"
]
}
# Example: OPC Skills Banner Creation **User:** Create a banner for opc.dev GitHub README. Should match the existing pixel art logo style. **Agent:** I'll create a banner for OPC Skills. Let me confirm the requirements: 1. Target size: GitHub README banner (typically 1280x640, 2:1 ratio) 2. Style: Pixel art to match existing crowned king logo 3. Text: "opc.dev" and "Agent Skills" 4. Character: Include the pixel art crowned king from logo **User:** Yes, that's correct. Please generate some options. **Agent:** Generating banner variations at 16:9 ratio first... Here are the generated banners:       **User:** I like the style. But GitHub banners work best at 2:1 ratio. Can you generate some at that ratio? **Agent:** Generating at 2:1 ratio for GitHub:    **User:** Perfect! Let's use the third one. **Agent:** Here's the final banner:  The banner is ready to use in your README!
# Banner Formats Reference ## Platform-Specific Sizes | Platform | Size (px) | Ratio | Notes | |----------|-----------|-------|-------| | **GitHub README** | 1280×640 | 2:1 | Most common for project banners | | **Twitter/X Header** | 1500×500 | 3:1 | Profile header image | | **LinkedIn Banner** | 1584×396 | 4:1 | Personal profile background | | **LinkedIn Company** | 1128×191 | ~6:1 | Company page banner | | **YouTube Channel** | 2560×1440 | 16:9 | Channel art (safe area: 1546×423) | | **Facebook Cover** | 820×312 | ~2.6:1 | Personal profile | | **Discord Server** | 960×540 | 16:9 | Server banner | | **Website Hero** | 1920×1080 | 16:9 | Full-width hero section | | **Website Hero (tall)** | 1920×800 | 2.4:1 | Shorter hero section | | **Email Header** | 600×200 | 3:1 | Newsletter headers | | **Product Hunt** | 1270×760 | ~1.67:1 | Gallery images | ## Generation Strategy Since `nano-banana-pro` supports these ratios: - `1:1`, `2:3`, `3:2`, `3:4`, `4:3`, `4:5`, `5:4`, `9:16`, `16:9`, `21:9` **Recommended approach:** 1. Generate at `21:9` (widest available) 2. Crop to target ratio using `crop_banner.py` This ensures: - Maximum flexibility for different platforms - Content centered properly - No stretching or distortion ## Ratio Quick Reference ``` 21:9 ████████████████████████████████ (ultra-wide, cinematic) 16:9 ████████████████████████ (widescreen) 3:1 ████████████████████████████████ (Twitter header) 2:1 ████████████████ (GitHub README) 4:1 ████████████████████████████████████████████ (LinkedIn) ``` ## File Size Guidelines - **GitHub**: < 10MB, PNG or JPG - **Twitter**: < 5MB, PNG, JPG, or GIF - **LinkedIn**: < 8MB, PNG or JPG - **Website**: Optimize for web (< 500KB ideally) ## Tips 1. **Safe zones**: Keep important content (text, logo) in center 60% for platforms that crop on mobile 2. **Text legibility**: Use high contrast, avoid small text 3. **Brand consistency**: Match colors and style with existing logo 4. **Mobile preview**: Check how banner looks on mobile (often cropped)
#!/usr/bin/env python3
"""
Crop banner to target aspect ratio.
Usage:
python crop_banner.py input.png output.png --ratio 2:1 --width 1280
python crop_banner.py input.png output.png --ratio 16:9
python crop_banner.py input.png output.png --size 1500x500
"""
import argparse
import sys
from PIL import Image
def parse_ratio(ratio_str: str) -> tuple[int, int]:
"""Parse ratio string like '2:1' or '16:9' into (width, height) parts."""
parts = ratio_str.split(':')
if len(parts) != 2:
raise ValueError(f"Invalid ratio format: {ratio_str}. Use format like '2:1' or '16:9'")
return int(parts[0]), int(parts[1])
def parse_size(size_str: str) -> tuple[int, int]:
"""Parse size string like '1280x640' into (width, height)."""
parts = size_str.lower().split('x')
if len(parts) != 2:
raise ValueError(f"Invalid size format: {size_str}. Use format like '1280x640'")
return int(parts[0]), int(parts[1])
def crop_to_ratio(img: Image.Image, target_ratio: tuple[int, int]) -> Image.Image:
"""Crop image to target aspect ratio, centered."""
orig_width, orig_height = img.size
target_w, target_h = target_ratio
target_aspect = target_w / target_h
orig_aspect = orig_width / orig_height
if orig_aspect > target_aspect:
# Image is wider than target, crop width
new_width = int(orig_height * target_aspect)
new_height = orig_height
left = (orig_width - new_width) // 2
top = 0
else:
# Image is taller than target, crop height
new_width = orig_width
new_height = int(orig_width / target_aspect)
left = 0
top = (orig_height - new_height) // 2
right = left + new_width
bottom = top + new_height
return img.crop((left, top, right, bottom))
def main():
parser = argparse.ArgumentParser(description='Crop banner to target aspect ratio')
parser.add_argument('input', help='Input image path')
parser.add_argument('output', help='Output image path')
parser.add_argument('--ratio', '-r', help='Target aspect ratio (e.g., 2:1, 16:9)')
parser.add_argument('--size', '-s', help='Target size (e.g., 1280x640)')
parser.add_argument('--width', '-w', type=int, help='Target width (maintains ratio)')
parser.add_argument('--height', type=int, help='Target height (maintains ratio)')
args = parser.parse_args()
if not args.ratio and not args.size:
print("Error: Must specify --ratio or --size", file=sys.stderr)
sys.exit(1)
try:
img = Image.open(args.input)
print(f"Input: {img.size[0]}x{img.size[1]}")
if args.size:
target_w, target_h = parse_size(args.size)
target_ratio = (target_w, target_h)
else:
target_ratio = parse_ratio(args.ratio)
target_w, target_h = None, None
# Crop to ratio
cropped = crop_to_ratio(img, target_ratio)
print(f"Cropped: {cropped.size[0]}x{cropped.size[1]}")
# Resize if target dimensions specified
if args.size:
cropped = cropped.resize((target_w, target_h), Image.Resampling.LANCZOS)
print(f"Resized: {cropped.size[0]}x{cropped.size[1]}")
elif args.width:
ratio = args.width / cropped.size[0]
new_height = int(cropped.size[1] * ratio)
cropped = cropped.resize((args.width, new_height), Image.Resampling.LANCZOS)
print(f"Resized: {cropped.size[0]}x{cropped.size[1]}")
elif args.height:
ratio = args.height / cropped.size[1]
new_width = int(cropped.size[0] * ratio)
cropped = cropped.resize((new_width, args.height), Image.Resampling.LANCZOS)
print(f"Resized: {cropped.size[0]}x{cropped.size[1]}")
cropped.save(args.output)
print(f"Saved: {args.output}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Banner Preview</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a1a;
color: #fff;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 20px;
font-weight: 300;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
max-width: 1600px;
margin: 0 auto;
}
.card {
background: #2a2a2a;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.card img {
width: 100%;
height: auto;
display: block;
}
.card-info {
padding: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.card-number {
font-size: 14px;
font-weight: 600;
color: #888;
}
.card-fav {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
opacity: 0.3;
transition: opacity 0.2s;
}
.card-fav:hover, .card-fav.active {
opacity: 1;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 40px;
}
.modal.active { display: flex; }
.modal img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.modal-close {
position: absolute;
top: 20px;
right: 30px;
font-size: 40px;
color: #fff;
cursor: pointer;
opacity: 0.7;
}
.modal-close:hover { opacity: 1; }
.modal-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 60px;
color: #fff;
cursor: pointer;
opacity: 0.5;
user-select: none;
}
.modal-nav:hover { opacity: 1; }
.modal-prev { left: 20px; }
.modal-next { right: 20px; }
.modal-info {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
}
/* Favorites summary */
.favorites {
position: fixed;
bottom: 20px;
right: 20px;
background: #333;
padding: 16px 20px;
border-radius: 8px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.favorites h3 {
font-size: 14px;
margin-bottom: 8px;
color: #888;
}
.favorites-list {
font-size: 16px;
font-weight: 600;
}
.favorites:empty { display: none; }
@media (max-width: 1200px) {
.grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<h1>Banner Variations</h1>
<div class="grid" id="grid"></div>
<div class="modal" id="modal">
<span class="modal-close" onclick="closeModal()">×</span>
<span class="modal-nav modal-prev" onclick="navModal(-1)">‹</span>
<img id="modal-img" src="" alt="">
<span class="modal-nav modal-next" onclick="navModal(1)">›</span>
<div class="modal-info" id="modal-info"></div>
</div>
<div class="favorites" id="favorites"></div>
<script>
// Configuration - UPDATE THIS
const BANNER_COUNT = 20;
const BANNER_PREFIX = 'banner-';
const BANNER_EXT = '.png';
let currentIndex = 0;
let favorites = new Set();
function init() {
const grid = document.getElementById('grid');
for (let i = 1; i <= BANNER_COUNT; i++) {
const num = String(i).padStart(2, '0');
const filename = `${BANNER_PREFIX}${num}${BANNER_EXT}`;
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<img src="${filename}" alt="Banner ${num}" onclick="openModal(${i-1})">
<div class="card-info">
<span class="card-number">#${num}</span>
<button class="card-fav" onclick="toggleFav(${i}, event)">★</button>
</div>
`;
grid.appendChild(card);
}
}
function openModal(index) {
currentIndex = index;
updateModal();
document.getElementById('modal').classList.add('active');
document.body.style.overflow = 'hidden';
}
function closeModal() {
document.getElementById('modal').classList.remove('active');
document.body.style.overflow = '';
}
function navModal(dir) {
currentIndex = (currentIndex + dir + BANNER_COUNT) % BANNER_COUNT;
updateModal();
}
function updateModal() {
const num = String(currentIndex + 1).padStart(2, '0');
const filename = `${BANNER_PREFIX}${num}${BANNER_EXT}`;
document.getElementById('modal-img').src = filename;
document.getElementById('modal-info').textContent = `Banner #${num}`;
}
function toggleFav(num, event) {
event.stopPropagation();
const btn = event.target;
if (favorites.has(num)) {
favorites.delete(num);
btn.classList.remove('active');
} else {
favorites.add(num);
btn.classList.add('active');
}
updateFavorites();
}
function updateFavorites() {
const el = document.getElementById('favorites');
if (favorites.size === 0) {
el.innerHTML = '';
} else {
const nums = Array.from(favorites).sort((a,b) => a-b).map(n => `#${String(n).padStart(2,'0')}`);
el.innerHTML = `<h3>Favorites</h3><div class="favorites-list">${nums.join(', ')}</div>`;
}
}
document.addEventListener('keydown', (e) => {
if (!document.getElementById('modal').classList.contains('active')) return;
if (e.key === 'Escape') closeModal();
if (e.key === 'ArrowLeft') navModal(-1);
if (e.key === 'ArrowRight') navModal(1);
});
init();
</script>
</body>
</html>