Skip to content
53 changes: 42 additions & 11 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions design/src/variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export interface VariantsOptions {
viewports?: string; // "desktop,tablet,mobile" — generates at multiple sizes
}

export function normalizeVariantCount(count: number): number {
if (!Number.isInteger(count) || count < 1) {
throw new Error("--count must be a positive integer");
}
return Math.min(count, 7);
}

const STYLE_VARIATIONS = [
"", // First variant uses the brief as-is
"Use a bolder, more dramatic visual style with stronger contrast and larger typography.",
Expand All @@ -29,6 +36,8 @@ const STYLE_VARIATIONS = [
"Use a playful, modern style with asymmetric layout and unexpected color accents.",
];

const VARIANT_TIMEOUT_MS = 240_000;

/**
* Generate a single variant with retry on 429.
*
Expand All @@ -42,6 +51,7 @@ export async function generateVariant(
size: string,
quality: string,
fetchFn: typeof globalThis.fetch = globalThis.fetch,
timeoutMs = VARIANT_TIMEOUT_MS,
): Promise<{ path: string; success: boolean; error?: string }> {
const maxRetries = 3;
const MAX_RETRY_AFTER_MS = 60_000; // cap honored Retry-After to bound stalls
Expand All @@ -58,7 +68,7 @@ export async function generateVariant(
skipLeadingDelay = false;

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 240_000);
const timeout = setTimeout(() => controller.abort(), timeoutMs);

try {
const response = await fetchFn("https://api.openai.com/v1/responses", {
Expand Down Expand Up @@ -125,7 +135,7 @@ export async function generateVariant(
} catch (err: any) {
clearTimeout(timeout);
if (err.name === "AbortError") {
return { path: outputPath, success: false, error: "Timeout (120s)" };
return { path: outputPath, success: false, error: `Timeout (${timeoutMs / 1000}s)` };
}
lastError = err.message;
}
Expand All @@ -138,6 +148,7 @@ export async function generateVariant(
* Generate N variants with staggered parallel execution.
*/
export async function variants(options: VariantsOptions): Promise<void> {
const count = normalizeVariantCount(options.count);
const apiKey = requireApiKey();
const baseBrief = options.briefFile
? parseBrief(options.briefFile, true)
Expand All @@ -153,7 +164,6 @@ export async function variants(options: VariantsOptions): Promise<void> {
return;
}

const count = Math.min(options.count, 7); // Cap at 7 style variations
const size = options.size || "1536x1024";

console.error(`Generating ${count} variants...`);
Expand Down
33 changes: 32 additions & 1 deletion design/test/variants-retry-after.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import fs from "fs";
import os from "os";
import path from "path";
import { generateVariant } from "../src/variants";
import { generateVariant, normalizeVariantCount } from "../src/variants";

// 1x1 transparent PNG, base64 — valid bytes that fs.writeFileSync can write.
const TINY_PNG_BASE64 =
Expand Down Expand Up @@ -131,3 +131,34 @@ describe("generateVariant Retry-After handling", () => {
expect(gap).toBeLessThan(500);
});
});

describe("normalizeVariantCount", () => {
test("rejects values that would silently generate no variants", () => {
expect(() => normalizeVariantCount(NaN)).toThrow("--count must be a positive integer");
expect(() => normalizeVariantCount(0)).toThrow("--count must be a positive integer");
expect(() => normalizeVariantCount(-2)).toThrow("--count must be a positive integer");
expect(() => normalizeVariantCount(2.5)).toThrow("--count must be a positive integer");
});

test("keeps valid counts and preserves the existing upper cap", () => {
expect(normalizeVariantCount(1)).toBe(1);
expect(normalizeVariantCount(3)).toBe(3);
expect(normalizeVariantCount(10)).toBe(7);
});
});

test("AbortError reports the configured timeout", async () => {
const fetchFn = ((_input: any, init?: any) => new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
});
})) as typeof fetch;

const result = await generateVariant(
"key", "prompt", path.join(os.tmpdir(), "gstack-timeout-test.png"),
"1024x1024", "high", fetchFn, 1,
);
expect(result).toMatchObject({ success: false, error: "Timeout (0.001s)" });
});
Loading
Loading