
Bulkgen
- 17 installs
- 1 repo stars
- Updated March 10, 2026
- oil-oil/bulkgen-skill
Helps with ai & agent building tasks.
About
bulkgen is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- bulkgen
- AI & Agent Building
- AI-coding skill
Bulkgen by the numbers
- 17 all-time installs (skills.sh)
- Ranked #10,875 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oil-oil/bulkgen-skill --skill bulkgenAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 17 |
|---|---|
| repo stars | ★ 1 |
| Last updated | March 10, 2026 |
| Repository | oil-oil/bulkgen-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
BulkGen Agent Skill
Generate AI images with BulkGen. Scripts are bundled at ~/.claude/skills/bulkgen/scripts/ — always use this full path.
Language: Always respond in the user's language. This skill is written in English for consistency, but all replies to the user should match their language.
---
API Key
No environment variable setup needed. When the user asks to generate images:
1. Check if BULKGEN_API_KEY is already set in the environment. 2. If not set, ask the user in their language to share their key (format: sk_live_...). They can get one at bulk-gen.com → user menu → API Keys. 3. Once received, pass it inline — do not export it or persist it anywhere:
BULKGEN_API_KEY="sk_live_..." node $SCRIPTS/generate.js ...If the user gets a 401 error, ask them to check their key or get a new one at bulk-gen.com.
---
Workflow
1. Clarify → If parameters are ambiguous, ask ratio + mode before generating 2. Generate → Run generate.js with the user's key inline 3. Preview → Always run build_preview.js and open the HTML immediately after
---
Before generating: clarify parameters
When the user asks for multiple images without specifying ratio or mode, ask before generating.
Ask when: User requests N images without specifying ratio or mode.
Skip asking when: Single image (solo mode), or all parameters already specified.
When asking, explain the two choices in the user's language:
- Ratio: square (1:1), portrait (9:16), landscape (16:9) — default square
- Mode: variation (one prompt, multiple styles) vs batch (different prompt per cell) — default variation
If the user says they don't mind or leaves it to you, use defaults (1:1 + variation) and confirm briefly.
---
Quick start
SCRIPTS=~/.claude/skills/bulkgen/scripts
KEY="sk_live_..." # key provided by the user
# Single image
BULKGEN_API_KEY=$KEY node $SCRIPTS/generate.js --prompts "a sunset" --mode solo
# 3x3 variations (same prompt, different styles)
BULKGEN_API_KEY=$KEY node $SCRIPTS/generate.js --prompts "cyberpunk city" --mode variation --cols 3 --rows 3 --canvas-ratio 1:1
# 2x2 batch (different prompts per cell)
BULKGEN_API_KEY=$KEY node $SCRIPTS/generate.js --prompts "cat" "dog" "bird" "fish" --cols 2 --rows 2
# Edit with reference image
BULKGEN_API_KEY=$KEY node $SCRIPTS/generate.js --prompts "watercolor style" --input ./photo.jpg
# Build preview and open (always do this after generating)
node $SCRIPTS/build_preview.js ./bulkgen-result.json ./bulkgen-preview.html && open ./bulkgen-preview.html---
Options
| Option | Values | Default |
|---|---|---|
--mode | solo, batch, variation | variation |
--cols, --rows | Grid dimensions | auto |
--canvas-ratio | 1:1, 16:9, 9:16, 4:5, 3:4, 3:2, 2:3, 4:3, 5:4, 21:9 | 1:1 |
--resolution | 1K, 2K, 4K | 1K |
--input | Reference image path | none |
---
Modes
| Mode | Use when |
|---|---|
solo | Single image |
variation | One prompt → multiple creative variants (same subject, different styles) |
batch | Different prompts → each cell gets its own independent scene |
---
Layouts
Valid: 1x1, 2x1, 1x2, 3x1, 1x3, 2x2, 3x2, 2x3, 4x2, 2x4, 3x3, 4x3, 3x4, 4x4
--canvas-ratio is the aspect ratio of the full grid, not a single cell. Some layout/ratio combinations are unsupported — the script will error and suggest alternatives.
---
Reference images
Use --input for style transfer or editing. Up to 14 images, 7 MB each. Formats: PNG, JPG, WebP, HEIC, HEIF.
---
Post-generation
Image URLs expire in 12 hours — always build the preview immediately.
SCRIPTS=~/.claude/skills/bulkgen/scripts
# Build HTML preview (always run this)
node $SCRIPTS/build_preview.js ./bulkgen-result.json ./bulkgen-preview.html && open ./bulkgen-preview.html
# Download permanent local copies (only if user explicitly asks)
node $SCRIPTS/download_images.js ./bulkgen-result.json ./downloads---
Errors
| Status | Action |
|---|---|
| 401 | Invalid key — ask user to check or get a new one at bulk-gen.com |
| 402 | Insufficient credits — ask user to top up at bulk-gen.com |
| 400 | Invalid params — check layout/ratio compatibility |
| 500 | Server error — retry once |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{TITLE}}</title>
<style>
:root {
--bg: #f5f5f4;
--panel: #ffffff;
--border: #e4e4e7;
--text: #18181b;
--muted: #71717a;
--radius: 16px;
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
}
.shell {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px 72px;
display: grid;
gap: 20px;
}
/* ── Header ── */
.header {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
box-shadow: var(--shadow);
}
.header-left h1 {
margin: 0 0 4px;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
}
.header-left p {
margin: 0;
font-size: 13px;
color: var(--muted);
}
.meta-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pill {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 2px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 14px;
background: #fafafa;
min-width: 72px;
}
.pill-label {
font-size: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pill-value {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.02em;
}
/* ── Original grid image ── */
.original-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
box-shadow: var(--shadow);
}
.panel-title {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 14px;
}
.original-img {
display: block;
width: 100%;
max-width: 720px;
height: auto;
border-radius: 10px;
border: 1px solid var(--border);
}
/* ── Tile grid ── */
.grid-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
box-shadow: var(--shadow);
}
.grid-preview {
display: grid;
gap: 10px;
grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
}
.cell {
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
background: #fafafa;
}
.cell-image {
aspect-ratio: var(--aspect-ratio);
background: #e4e4e7;
}
.cell-image img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.cell-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.cell-index {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.download-link {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
text-decoration: none;
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
transition: background 100ms, border-color 100ms;
}
.download-link:hover {
background: #f4f4f5;
border-color: #d4d4d8;
}
/* ── Prompts ── */
.prompts-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px 24px;
box-shadow: var(--shadow);
}
.prompt-list {
display: grid;
gap: 8px;
}
.prompt-item {
display: flex;
gap: 10px;
align-items: baseline;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fafafa;
}
.prompt-num {
font-size: 11px;
font-weight: 600;
color: var(--muted);
min-width: 20px;
}
.prompt-text {
font-size: 13px;
color: var(--text);
line-height: 1.5;
word-break: break-word;
}
/* ── Footer ── */
.footer-note {
font-size: 12px;
color: var(--muted);
text-align: center;
margin: 0;
}
</style>
</head>
<body>
<main class="shell">
<header class="header">
<div class="header-left">
<h1>{{TITLE}}</h1>
<p>{{SUBTITLE}}</p>
</div>
<div class="meta-pills">
<div class="pill"><span class="pill-label">Mode</span><span class="pill-value">{{MODE}}</span></div>
<div class="pill"><span class="pill-label">Layout</span><span class="pill-value">{{COLS}}×{{ROWS}}</span></div>
<div class="pill"><span class="pill-label">Images</span><span class="pill-value">{{IMAGE_COUNT}}</span></div>
<div class="pill"><span class="pill-label">Resolution</span><span class="pill-value">{{RESOLUTION}}</span></div>
</div>
</header>
{{ORIGINAL_GRID_SECTION}}
<section class="grid-panel">
<p class="panel-title">Individual tiles — click to download</p>
<div class="grid-preview" style="--cols: {{COLS}}; --aspect-ratio: {{ASPECT_RATIO}};">
{{GRID_ITEMS}}
</div>
</section>
{{PROMPTS_SECTION}}
<p class="footer-note">Generated with BulkGen — signed URLs expire in 12 hours, download your images promptly.</p>
</main>
</body>
</html>
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
function usage() {
console.error("Usage: node build_preview.js <input.json> [output.html]");
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/\"/g, """)
.replace(/'/g, "'");
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function normalizePrompt(prompts, index) {
if (!Array.isArray(prompts)) return "";
const value = prompts[index];
return typeof value === "string" ? value : "";
}
function normalizeImage(image, index) {
if (!image || typeof image !== "object") {
throw new Error(`images[${index}] must be an object.`);
}
if (typeof image.url !== "string" || image.url.length === 0) {
throw new Error(`images[${index}].url is required.`);
}
return {
id: typeof image.id === "string" ? image.id : `image-${index + 1}`,
url: image.url,
filePath: typeof image.filePath === "string" ? image.filePath : "",
expiresAt: typeof image.expiresAt === "string" ? image.expiresAt : "",
};
}
function buildGridItems(images) {
return images
.map(
(image, index) => `
<article class="cell">
<div class="cell-image">
<img src="${escapeHtml(image.url)}" alt="Generated image ${index + 1}" loading="lazy" />
</div>
<div class="cell-footer">
<span class="cell-index">#${index + 1}</span>
<a class="download-link" href="${escapeHtml(image.url)}" download>Download</a>
</div>
</article>`,
)
.join("\n");
}
function buildOriginalGridSection(gridUrl) {
if (!gridUrl) return "";
return `
<section class="original-panel">
<p class="panel-title">Original grid (uncropped)</p>
<img class="original-img" src="${escapeHtml(gridUrl)}" alt="Original grid image" loading="lazy" />
</section>`;
}
function buildPromptsSection(images, prompts) {
if (!Array.isArray(prompts) || prompts.length === 0) return "";
const items = images
.map((_, index) => {
const prompt = normalizePrompt(prompts, index);
if (!prompt) return "";
return `
<div class="prompt-item">
<span class="prompt-num">#${index + 1}</span>
<span class="prompt-text">${escapeHtml(prompt)}</span>
</div>`;
})
.filter(Boolean)
.join("\n");
if (!items) return "";
return `
<section class="prompts-panel">
<p class="panel-title">Prompts</p>
<div class="prompt-list">${items}
</div>
</section>`;
}
function replaceAll(template, replacements) {
return Object.entries(replacements).reduce(
(output, [key, value]) => output.replace(new RegExp(`{{${key}}}`, "g"), value),
template,
);
}
function main() {
const inputPath = process.argv[2];
const outputPath = process.argv[3] || path.resolve(process.cwd(), "bulkgen-preview.html");
if (!inputPath) {
usage();
process.exit(1);
}
const input = readJson(path.resolve(process.cwd(), inputPath));
const images = Array.isArray(input.images) ? input.images.map(normalizeImage) : [];
if (images.length === 0) {
throw new Error("Input JSON must include a non-empty images array.");
}
const cols = Number.isInteger(input.cols) && input.cols > 0 ? input.cols : Math.min(images.length, 4);
const rows = Number.isInteger(input.rows) && input.rows > 0 ? input.rows : Math.ceil(images.length / cols);
const resolution = typeof input.resolution === "string" && input.resolution ? input.resolution : "1K";
const mode = typeof input.mode === "string" && input.mode ? input.mode : "batch";
const title = typeof input.title === "string" && input.title ? input.title : `BulkGen ${cols}×${rows} Preview`;
const subtitle = typeof input.subtitle === "string" && input.subtitle
? input.subtitle
: "A lightweight preview page for this BulkGen generation, with a grid overview and per-image downloads.";
const aspectRatio = typeof input.aspectRatio === "string" && /^\d+(\.\d+)?:\d+(\.\d+)?$/.test(input.aspectRatio)
? input.aspectRatio.replace(":", " / ")
: "1 / 1";
const templatePath = path.join(__dirname, "..", "assets", "html-preview-template", "template.html");
const template = fs.readFileSync(templatePath, "utf8");
const gridUrl = typeof input.gridUrl === "string" && input.gridUrl ? input.gridUrl : "";
const html = replaceAll(template, {
TITLE: escapeHtml(title),
SUBTITLE: escapeHtml(subtitle),
MODE: escapeHtml(mode),
COLS: String(cols),
ROWS: String(rows),
IMAGE_COUNT: String(images.length),
RESOLUTION: escapeHtml(resolution),
ASPECT_RATIO: aspectRatio,
GRID_ITEMS: buildGridItems(images),
ORIGINAL_GRID_SECTION: buildOriginalGridSection(gridUrl),
PROMPTS_SECTION: buildPromptsSection(images, input.prompts),
});
fs.writeFileSync(outputPath, html);
console.log(`Wrote ${outputPath}`);
}
main();
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
function usage() {
console.error("Usage: node scripts/download_images.js <result.json> [output-dir]");
}
function ensureFetch() {
if (typeof fetch !== "function") {
throw new Error("This script requires Node.js 18+ with global fetch support.");
}
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function sanitizeSegment(value, fallback) {
const cleaned = String(value || fallback)
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 120);
return cleaned || fallback;
}
function guessExtension(image, response) {
const filePath = typeof image.filePath === "string" ? image.filePath : "";
const fileExt = path.extname(filePath);
if (fileExt) return fileExt.toLowerCase();
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("image/png")) return ".png";
if (contentType.includes("image/jpeg")) return ".jpg";
if (contentType.includes("image/webp")) return ".webp";
if (contentType.includes("image/gif")) return ".gif";
const url = typeof image.url === "string" ? image.url : "";
try {
const pathname = new URL(url).pathname;
const urlExt = path.extname(pathname);
if (urlExt) return urlExt.toLowerCase();
} catch {}
return ".png";
}
function normalizeImage(image, index) {
if (!image || typeof image !== "object") {
throw new Error(`images[${index}] must be an object.`);
}
if (typeof image.url !== "string" || image.url.length === 0) {
throw new Error(`images[${index}].url is required.`);
}
return {
id: typeof image.id === "string" ? image.id : `image-${index + 1}`,
url: image.url,
filePath: typeof image.filePath === "string" ? image.filePath : "",
expiresAt: typeof image.expiresAt === "string" ? image.expiresAt : "",
};
}
function buildFileName(image, index, extension) {
const numericPrefix = String(index + 1).padStart(2, "0");
const fromPath = image.filePath ? path.basename(image.filePath, path.extname(image.filePath)) : "";
const stem = sanitizeSegment(fromPath || image.id, `image-${numericPrefix}`);
return `${numericPrefix}-${stem}${extension}`;
}
async function downloadOne(image, index, outputDir) {
const response = await fetch(image.url);
if (!response.ok) {
throw new Error(`Failed to download image ${index + 1}: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const extension = guessExtension(image, response);
const fileName = buildFileName(image, index, extension);
const outputPath = path.join(outputDir, fileName);
fs.writeFileSync(outputPath, buffer);
return {
index: index + 1,
id: image.id,
sourceUrl: image.url,
filePath: image.filePath,
expiresAt: image.expiresAt,
localPath: outputPath,
sizeBytes: buffer.length,
};
}
async function main() {
ensureFetch();
const inputArg = process.argv[2];
const outputArg = process.argv[3] || "bulkgen-downloads";
if (!inputArg) {
usage();
process.exit(1);
}
const inputPath = path.resolve(process.cwd(), inputArg);
const outputDir = path.resolve(process.cwd(), outputArg);
const payload = readJson(inputPath);
const images = Array.isArray(payload.images) ? payload.images.map(normalizeImage) : [];
if (images.length === 0) {
throw new Error("Input JSON must include a non-empty images array.");
}
fs.mkdirSync(outputDir, { recursive: true });
const downloads = [];
for (let index = 0; index < images.length; index += 1) {
const image = images[index];
const item = await downloadOne(image, index, outputDir);
downloads.push(item);
console.log(`Downloaded ${item.localPath}`);
}
const manifest = {
source: inputPath,
downloadedAt: new Date().toISOString(),
expiresAt: typeof payload.expiresAt === "string" ? payload.expiresAt : null,
imageCount: downloads.length,
items: downloads,
};
const manifestPath = path.join(outputDir, "manifest.json");
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`Wrote ${manifestPath}`);
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
#!/usr/bin/env node
/**
* BulkGen API client - generate AI images via command line
*
* Usage:
* node generate.js --prompts "prompt1" "prompt2" --mode batch --cols 2 --rows 2
* node generate.js --prompts "style transfer prompt" --input ./image.png
* node generate.js --help
*/
const fs = require("fs");
const path = require("path");
const SUPPORTED_MIME_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
};
const SUPPORTED_RATIOS = ["1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "9:16", "4:5", "5:4", "21:9"];
const VALID_LAYOUTS = {
1: [[1, 1]],
2: [[2, 1], [1, 2]],
3: [[3, 1], [1, 3]],
4: [[2, 2]],
6: [[3, 2], [2, 3]],
8: [[4, 2], [2, 4]],
9: [[3, 3]],
12: [[4, 3], [3, 4]],
16: [[4, 4]],
};
const MAX_SOURCE_RATIO_ERROR = 0.12;
const ASPECT_EPSILON = 0.0001;
const SUPPORTED_ASPECT_VALUES = SUPPORTED_RATIOS.map(ratioToNumber);
const MIN_SUPPORTED_TILE_ASPECT = Math.min(...SUPPORTED_ASPECT_VALUES);
const MAX_SUPPORTED_TILE_ASPECT = Math.max(...SUPPORTED_ASPECT_VALUES);
function usage() {
console.log(`
BulkGen API Client - Generate AI images
USAGE
node generate.js [options]
OPTIONS
--prompts <text> One or more prompts (required, can repeat)
--mode <type> solo | batch | variation (default: batch)
--cols <n> Grid columns (default: auto)
--rows <n> Grid rows (default: auto)
--resolution <level> 1K | 2K | 4K (default: 1K)
--canvas-ratio <ratio> Full output canvas aspect ratio (default: 1:1)
--source-ratio <ratio> Optional source aspect ratio override
--input <path> Reference image(s) for editing (can repeat)
--output <path> Output JSON file path (default: ./bulkgen-result.json)
--api-key <key> API key (or set BULKGEN_API_KEY env var)
--help Show this help
MODES
solo One prompt → one image (1x1 only)
batch Multiple prompts → multiple distinct images
variation One prompt → multiple creative variants
EXAMPLES
# Single image
node generate.js --prompts "a sunset over mountains" --mode solo
# 2x2 batch on a square canvas
node generate.js --prompts "cat" "dog" "bird" "fish" --cols 2 --rows 2 --canvas-ratio 1:1
# 3x3 variations on a portrait canvas
node generate.js --prompts "cyberpunk city" --mode variation --cols 3 --rows 3 --canvas-ratio 4:5
# Edit image with reference
node generate.js --prompts "make it watercolor style" --input ./photo.jpg
# High resolution
node generate.js --prompts "product shot" --resolution 4K
`);
}
function parseArgs(args) {
const result = {
prompts: [],
mode: "batch",
cols: null,
rows: null,
resolution: "1K",
canvasRatio: null,
sourceRatio: null,
inputImages: [],
outputPath: "./bulkgen-result.json",
apiKey: null,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--help" || arg === "-h") {
usage();
process.exit(0);
}
if (arg === "--prompts" || arg === "--prompt") {
while (i + 1 < args.length && !args[i + 1].startsWith("--")) {
result.prompts.push(args[++i]);
}
continue;
}
if (arg === "--mode") {
result.mode = args[++i];
continue;
}
if (arg === "--cols") {
result.cols = parseInt(args[++i], 10);
continue;
}
if (arg === "--rows") {
result.rows = parseInt(args[++i], 10);
continue;
}
if (arg === "--resolution") {
result.resolution = args[++i];
continue;
}
if (arg === "--canvas-ratio") {
result.canvasRatio = args[++i];
continue;
}
if (arg === "--source-ratio") {
result.sourceRatio = args[++i];
continue;
}
if (arg === "--input" || arg === "--input-image") {
result.inputImages.push(args[++i]);
continue;
}
if (arg === "--output" || arg === "-o") {
result.outputPath = args[++i];
continue;
}
if (arg === "--api-key") {
result.apiKey = args[++i];
continue;
}
}
return result;
}
function validateParams(params) {
const errors = [];
if (params.prompts.length === 0) {
errors.push("At least one --prompts value is required.");
}
if (!["solo", "batch", "variation"].includes(params.mode)) {
errors.push(`Invalid mode "${params.mode}". Use: solo, batch, or variation.`);
}
if (params.canvasRatio && !SUPPORTED_RATIOS.includes(params.canvasRatio)) {
errors.push(`Invalid canvas-ratio "${params.canvasRatio}". Supported: ${SUPPORTED_RATIOS.join(", ")}`);
}
if (params.sourceRatio && !SUPPORTED_RATIOS.includes(params.sourceRatio)) {
errors.push(`Invalid source-ratio "${params.sourceRatio}". Supported: ${SUPPORTED_RATIOS.join(", ")}`);
}
if (!["1K", "2K", "4K"].includes(params.resolution)) {
errors.push(`Invalid resolution "${params.resolution}". Use: 1K, 2K, or 4K.`);
}
let cols = params.cols;
let rows = params.rows;
const requestedCanvasRatio = params.canvasRatio || "1:1";
if (params.mode === "solo") {
cols = 1;
rows = 1;
} else if (!cols || !rows) {
const desiredCount = params.mode === "variation" ? 4 : params.prompts.length;
[cols, rows] = findBestLayout(desiredCount, requestedCanvasRatio);
}
const cellCount = cols * rows;
const validLayout = VALID_LAYOUTS[cellCount]?.some(([c, r]) => c === cols && r === rows);
if (!validLayout) {
const options = Object.entries(VALID_LAYOUTS)
.map(([, layouts]) => layouts.map(([c, r]) => `${c}x${r}`).join(", "))
.join("; ");
errors.push(`Invalid layout ${cols}x${rows}. Valid options: ${options}`);
}
if (params.mode === "solo" && cellCount !== 1) {
errors.push("Solo mode requires a 1x1 layout.");
}
if (params.mode !== "solo" && cellCount < 2) {
errors.push("Batch and variation modes require at least 2 cells.");
}
const suggestedSourceRatio = resolveSourceRatioForLayout(cols, rows, requestedCanvasRatio);
if (!suggestedSourceRatio) {
errors.push(`Layout ${cols}x${rows} does not support canvas-ratio "${requestedCanvasRatio}".`);
} else if (params.sourceRatio && !isValidRatioLayoutCombo(cols, rows, requestedCanvasRatio, params.sourceRatio)) {
errors.push(
`source-ratio "${params.sourceRatio}" is not compatible with layout ${cols}x${rows} and canvas-ratio "${requestedCanvasRatio}". Try --source-ratio ${suggestedSourceRatio}.`
);
}
return {
cols,
rows,
canvasRatio: requestedCanvasRatio,
sourceRatio: params.sourceRatio || suggestedSourceRatio,
tileRatio: getTileRatioForCanvasLayout(cols, rows, requestedCanvasRatio),
errors,
};
}
function findBestLayout(count, canvasRatio) {
const cellCounts = Object.keys(VALID_LAYOUTS)
.map((value) => parseInt(value, 10))
.sort((left, right) => left - right);
for (const cellCount of cellCounts) {
if (cellCount < count) continue;
const layouts = getSortedLayoutCandidates(VALID_LAYOUTS[cellCount], canvasRatio);
const valid = layouts.find((candidate) => isValidLayoutCandidate(candidate));
if (valid) {
return [valid.cols, valid.rows];
}
if (layouts[0]) {
return [layouts[0].cols, layouts[0].rows];
}
}
return [4, 4];
}
function ratioToNumber(ratio) {
const [widthRaw, heightRaw] = ratio.split(":");
const width = Number(widthRaw);
const height = Number(heightRaw);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
}
return width / height;
}
function getAspectOrientation(aspect) {
if (Math.abs(aspect - 1) < ASPECT_EPSILON) return "square";
return aspect > 1 ? "landscape" : "portrait";
}
function isTileOrientationCompatible(canvasAspect, tileAspect) {
const canvasOrientation = getAspectOrientation(canvasAspect);
if (canvasOrientation === "square") return true;
return getAspectOrientation(tileAspect) === canvasOrientation;
}
function isTileAspectWithinSupportedRange(tileAspect) {
return tileAspect >= MIN_SUPPORTED_TILE_ASPECT - ASPECT_EPSILON && tileAspect <= MAX_SUPPORTED_TILE_ASPECT + ASPECT_EPSILON;
}
function getClosestModelRatio(aspect) {
return SUPPORTED_RATIOS.reduce((best, current) => {
const bestDelta = Math.abs(ratioToNumber(best) - aspect);
const currentDelta = Math.abs(ratioToNumber(current) - aspect);
return currentDelta < bestDelta ? current : best;
});
}
function buildLayoutCandidate(cols, rows, canvasRatio, sourceRatio) {
const canvasAspect = ratioToNumber(canvasRatio);
const tileAspect = (canvasAspect * rows) / cols;
const resolvedSourceRatio = sourceRatio || getClosestModelRatio(canvasAspect);
const sourceAspect = ratioToNumber(resolvedSourceRatio);
return {
cols,
rows,
sourceRatio: resolvedSourceRatio,
canvasAspect,
tileAspect,
sourceAspectError: Math.abs(sourceAspect - canvasAspect) / canvasAspect,
tileWithinSupportedRange: isTileAspectWithinSupportedRange(tileAspect),
tileOrientationCompatible: isTileOrientationCompatible(canvasAspect, tileAspect),
};
}
function isValidLayoutCandidate(candidate) {
return (
candidate.sourceAspectError <= MAX_SOURCE_RATIO_ERROR &&
candidate.tileWithinSupportedRange &&
candidate.tileOrientationCompatible
);
}
function getCandidateScore(candidate) {
const orientationPenalty = candidate.tileOrientationCompatible ? 0 : 100;
const rangePenalty = candidate.tileWithinSupportedRange
? 0
: candidate.tileAspect < MIN_SUPPORTED_TILE_ASPECT
? MIN_SUPPORTED_TILE_ASPECT - candidate.tileAspect
: candidate.tileAspect - MAX_SUPPORTED_TILE_ASPECT;
return orientationPenalty + rangePenalty + candidate.sourceAspectError;
}
function getSortedLayoutCandidates(layouts, canvasRatio) {
return layouts
.map(([cols, rows]) => buildLayoutCandidate(cols, rows, canvasRatio))
.sort((left, right) => getCandidateScore(left) - getCandidateScore(right));
}
function resolveSourceRatioForLayout(cols, rows, canvasRatio) {
const candidate = buildLayoutCandidate(cols, rows, canvasRatio);
return isValidLayoutCandidate(candidate) ? candidate.sourceRatio : null;
}
function isValidRatioLayoutCombo(cols, rows, canvasRatio, sourceRatio) {
return isValidLayoutCandidate(buildLayoutCandidate(cols, rows, canvasRatio, sourceRatio));
}
function getTileAspectForCanvasLayout(cols, rows, canvasRatio) {
return (ratioToNumber(canvasRatio) * rows) / cols;
}
function aspectToRatioString(aspect) {
if (!Number.isFinite(aspect) || aspect <= 0) return "1:1";
const scale = 1000;
let width = Math.max(1, Math.round(aspect * scale));
let height = scale;
const gcd = (left, right) => (right === 0 ? left : gcd(right, left % right));
const divisor = gcd(width, height);
width /= divisor;
height /= divisor;
return `${width}:${height}`;
}
function getTileRatioForCanvasLayout(cols, rows, canvasRatio) {
return aspectToRatioString(getTileAspectForCanvasLayout(cols, rows, canvasRatio));
}
function encodeImage(filePath) {
const absolutePath = path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`Input image not found: ${filePath}`);
}
const ext = path.extname(absolutePath).toLowerCase();
const mimeType = SUPPORTED_MIME_TYPES[ext];
if (!mimeType) {
throw new Error(`Unsupported image format: ${ext}. Supported: ${Object.keys(SUPPORTED_MIME_TYPES).join(", ")}`);
}
const stats = fs.statSync(absolutePath);
const sizeMB = stats.size / (1024 * 1024);
if (sizeMB > 7) {
throw new Error(`Image too large: ${filePath} (${sizeMB.toFixed(1)} MB). Limit: 7 MB.`);
}
const buffer = fs.readFileSync(absolutePath);
const base64 = buffer.toString("base64");
return { mimeType, dataBase64: base64 };
}
async function callAPI(params, prepared) {
const apiKey = params.apiKey || process.env.BULKGEN_API_KEY;
if (!apiKey) {
throw new Error(
"Missing API key. Set BULKGEN_API_KEY environment variable or use --api-key option.\n" +
"Get your key at https://bulk-gen.com (user menu → API Keys)"
);
}
const inputImagePayloads = params.inputImages.map(encodeImage);
const requestBody = {
mode: params.mode,
cols: prepared.cols,
rows: prepared.rows,
prompts: params.prompts,
resolution: params.resolution,
canvasRatio: prepared.canvasRatio,
};
if (prepared.sourceRatio) {
requestBody.sourceRatio = prepared.sourceRatio;
}
if (inputImagePayloads.length > 0) {
requestBody.inputImages = inputImagePayloads;
}
console.error(
`Calling BulkGen API (${params.mode}, ${prepared.cols}x${prepared.rows}, canvas ${prepared.canvasRatio}, ${params.resolution})...`
);
const response = await fetch("https://api.bulk-gen.com/api/v1/generate", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
const result = await response.json();
if (!response.ok) {
const errorMessage = result.error || `API error: ${response.status}`;
if (response.status === 401) {
throw new Error("Invalid API key. Get a new key at https://bulk-gen.com");
}
if (response.status === 402) {
const credits = result.credits || {};
throw new Error(
`Insufficient credits. Remaining: ${credits.remaining || 0}, Required: ${credits.required || "?"}. Top up at https://bulk-gen.com`
);
}
throw new Error(errorMessage);
}
return result;
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
usage();
process.exit(1);
}
const params = parseArgs(args);
const prepared = validateParams(params);
if (prepared.errors.length > 0) {
console.error("Errors:\n" + prepared.errors.map((error) => ` - ${error}`).join("\n"));
process.exit(1);
}
try {
const result = await callAPI(params, prepared);
const output = {
...result,
mode: params.mode,
cols: prepared.cols,
rows: prepared.rows,
resolution: params.resolution,
aspectRatio: prepared.canvasRatio,
canvasRatio: result.canvasRatio || prepared.canvasRatio,
sourceRatio: result.sourceRatio || prepared.sourceRatio,
tileRatio: result.tileRatio || prepared.tileRatio,
prompts: params.prompts,
};
fs.writeFileSync(params.outputPath, JSON.stringify(output, null, 2));
console.error(`\nGenerated ${result.images.length} image(s)`);
console.error(`Canvas ratio: ${output.canvasRatio}`);
console.error(`Tile ratio: ${output.tileRatio}`);
console.error(`Credits charged: ${result.credits?.charged || "?"}`);
console.error(`Credits remaining: ${result.credits?.remaining ?? "?"}`);
console.error(`\nResult saved to: ${params.outputPath}`);
console.error(`\nImage URLs:`);
result.images.forEach((img, i) => {
console.error(` ${i + 1}. ${img.url}`);
});
} catch (error) {
console.error(`\nError: ${error.message}`);
process.exit(1);
}
}
main();