fix: use Nano Banana directly and enforce 750x1200px zine dimensions

- Switch from RunPod proxy to direct Gemini API (no geo-blocking on gemini-2.5-flash-image)
- Add sharp post-processing to resize all images to exact 750x1200px
- Dimensions: 1/8 letter (2.5" x 4.0" content area with 0.125" margins) at 300 DPI
- Update regenerate-page FLUX img2img to use same dimensions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-21 13:41:31 +01:00
parent 23ace0a7f3
commit 8dc3145e5c
2 changed files with 67 additions and 44 deletions

View File

@ -2,6 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { getZine, saveZine, savePageImage } from "@/lib/storage"; import { getZine, saveZine, savePageImage } from "@/lib/storage";
import type { PageOutline } from "@/lib/gemini"; import type { PageOutline } from "@/lib/gemini";
// Zine page dimensions: 1/8 of US Letter (8.5" x 11") at 300 DPI
// Page size: 2.75" x 4.25", with 0.125" margin on each side
// Content area: 2.5" x 4.0"
const ZINE_PAGE_WIDTH = 750; // 2.5 inches * 300 DPI (2.75" - 0.25" margins)
const ZINE_PAGE_HEIGHT = 1200; // 4.0 inches * 300 DPI (4.25" - 0.25" margins)
// Style-specific image generation prompts // Style-specific image generation prompts
const STYLE_PROMPTS: Record<string, string> = { const STYLE_PROMPTS: Record<string, string> = {
"punk-zine": "xerox-style high contrast black and white, DIY cut-and-paste collage aesthetic, hand-drawn typography, punk rock zine style, grainy texture, photocopied look, bold graphic elements", "punk-zine": "xerox-style high contrast black and white, DIY cut-and-paste collage aesthetic, hand-drawn typography, punk rock zine style, grainy texture, photocopied look, bold graphic elements",
@ -74,7 +80,10 @@ export async function POST(request: NextRequest) {
} }
function buildImagePrompt(outline: PageOutline, stylePrompt: string, tonePrompt: string): string { function buildImagePrompt(outline: PageOutline, stylePrompt: string, tonePrompt: string): string {
return `Create a single zine page image (portrait orientation, 825x1275 pixels aspect ratio). return `Create a single zine page image for print.
EXACT DIMENSIONS: ${ZINE_PAGE_WIDTH}x${ZINE_PAGE_HEIGHT} pixels (portrait orientation, 2.5" x 4.0" at 300 DPI)
This is 1/8 of a US Letter page with 0.125" margins for an 8-page mini-zine fold.
PAGE ${outline.pageNumber}: "${outline.title}" PAGE ${outline.pageNumber}: "${outline.title}"
Type: ${outline.type} Type: ${outline.type}
@ -88,12 +97,12 @@ Mood/Tone: ${tonePrompt}
Detailed requirements: Detailed requirements:
${outline.imagePrompt} ${outline.imagePrompt}
IMPORTANT: CRITICAL REQUIREMENTS:
- This is a SINGLE page that will be printed - Portrait orientation ONLY (taller than wide)
- Include any text/typography as part of the graphic design
- Fill the entire page - no blank margins - Fill the entire page - no blank margins
- Make it visually striking and cohesive - Design must work at small print size (high contrast, clear details)
- The design should work in print (high contrast, clear details)`; - Include any text/typography as part of the graphic design
- Make it visually striking and cohesive`;
} }
async function generateImageWithGemini( async function generateImageWithGemini(
@ -102,17 +111,11 @@ async function generateImageWithGemini(
style: string style: string
): Promise<string> { ): Promise<string> {
const apiKey = process.env.GEMINI_API_KEY; const apiKey = process.env.GEMINI_API_KEY;
const runpodApiKey = process.env.RUNPOD_API_KEY;
const runpodEndpointId = process.env.RUNPOD_GEMINI_ENDPOINT_ID || "ntqjz8cdsth42i";
if (!apiKey) { if (!apiKey) {
throw new Error("GEMINI_API_KEY not configured"); throw new Error("GEMINI_API_KEY not configured");
} }
if (!runpodApiKey) {
throw new Error("RUNPOD_API_KEY not configured - required for US proxy");
}
// Enhanced prompt for better text rendering and image quality // Enhanced prompt for better text rendering and image quality
const enhancedPrompt = `${prompt} const enhancedPrompt = `${prompt}
@ -122,15 +125,17 @@ CRITICAL TEXT RENDERING INSTRUCTIONS:
- Avoid distorted or warped letters - Avoid distorted or warped letters
- Text should be integrated naturally into the design`; - Text should be integrated naturally into the design`;
// Use RunPod US proxy to call Nano Banana Pro (bypasses geo-blocking) // Call Nano Banana (Gemini 2.5 Flash Image) directly - no geo-blocking from EU
try { try {
const result = await generateWithNanoBananaProViaRunPod(enhancedPrompt, apiKey, runpodApiKey, runpodEndpointId); const result = await generateWithNanoBanana(enhancedPrompt, apiKey);
if (result) { if (result) {
console.log("✅ Generated image with Nano Banana Pro via RunPod US"); console.log("✅ Generated image with Nano Banana (gemini-2.5-flash-image)");
return result; // Resize to exact zine dimensions
const resizedImage = await resizeToZineDimensions(result);
return resizedImage;
} }
} catch (error) { } catch (error) {
console.error("Nano Banana Pro via RunPod error:", error); console.error("Nano Banana error:", error);
} }
// Final fallback: Create styled placeholder // Final fallback: Create styled placeholder
@ -138,50 +143,62 @@ CRITICAL TEXT RENDERING INSTRUCTIONS:
return createStyledPlaceholder(outline, style); return createStyledPlaceholder(outline, style);
} }
// Nano Banana Pro via RunPod US proxy (bypasses geo-blocking in Germany) // Resize image to exact zine page dimensions (1/8 letter)
async function generateWithNanoBananaProViaRunPod( async function resizeToZineDimensions(base64Image: string): Promise<string> {
const sharp = (await import("sharp")).default;
const inputBuffer = Buffer.from(base64Image, "base64");
// Resize to exact dimensions, covering the area and cropping excess
const resizedBuffer = await sharp(inputBuffer)
.resize(ZINE_PAGE_WIDTH, ZINE_PAGE_HEIGHT, {
fit: "cover", // Cover the dimensions, crop if needed
position: "center" // Center the crop
})
.png()
.toBuffer();
console.log(`✅ Resized image to ${ZINE_PAGE_WIDTH}x${ZINE_PAGE_HEIGHT}px`);
return resizedBuffer.toString("base64");
}
// Direct Nano Banana API call (Gemini 2.5 Flash Image)
async function generateWithNanoBanana(
prompt: string, prompt: string,
apiKey: string, apiKey: string
runpodApiKey: string,
endpointId: string
): Promise<string | null> { ): Promise<string | null> {
const runpodUrl = `https://api.runpod.ai/v2/${endpointId}/runsync`; const model = "gemini-2.5-flash-image";
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
console.log("Calling Nano Banana Pro via RunPod US proxy..."); console.log("Calling Nano Banana (gemini-2.5-flash-image)...");
const response = await fetch(runpodUrl, { const response = await fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${runpodApiKey}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
input: { contents: [
api_key: apiKey, {
model: "gemini-2.0-flash-exp-image-generation", parts: [{ text: prompt }],
contents: [
{
parts: [{ text: prompt }],
},
],
generationConfig: {
responseModalities: ["IMAGE"],
}, },
],
generationConfig: {
responseModalities: ["image", "text"],
}, },
}), }),
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error("RunPod API error:", response.status, errorText); console.error("Gemini API error:", response.status, errorText);
return null; return null;
} }
const result = await response.json(); const data = await response.json();
const data = result.output || result;
if (data.error) { if (data.error) {
console.error("Gemini API error via RunPod:", JSON.stringify(data.error)); console.error("Gemini API error:", JSON.stringify(data.error));
return null; return null;
} }
@ -189,7 +206,7 @@ async function generateWithNanoBananaProViaRunPod(
const parts = data.candidates?.[0]?.content?.parts || []; const parts = data.candidates?.[0]?.content?.parts || [];
for (const part of parts) { for (const part of parts) {
if (part.inlineData?.mimeType?.startsWith("image/")) { if (part.inlineData?.mimeType?.startsWith("image/")) {
console.log("✅ Successfully received image from Nano Banana Pro"); console.log("✅ Successfully received image from Nano Banana");
return part.inlineData.data; return part.inlineData.data;
} }
} }
@ -261,7 +278,7 @@ async function createStyledPlaceholder(
const pageType = escapeXml(outline.type.toUpperCase()); const pageType = escapeXml(outline.type.toUpperCase());
const svg = ` const svg = `
<svg width="825" height="1275" xmlns="http://www.w3.org/2000/svg"> <svg width="${ZINE_PAGE_WIDTH}" height="${ZINE_PAGE_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
${s.pattern} ${s.pattern}
<style> <style>

View File

@ -3,6 +3,12 @@ import { GoogleGenerativeAI } from "@google/generative-ai";
import { getZine, saveZine, getPageImagePath, readFileAsBase64, savePageImage } from "@/lib/storage"; import { getZine, saveZine, getPageImagePath, readFileAsBase64, savePageImage } from "@/lib/storage";
import type { PageOutline } from "@/lib/gemini"; import type { PageOutline } from "@/lib/gemini";
// Zine page dimensions: 1/8 of US Letter (8.5" x 11") at 300 DPI
// Page size: 2.75" x 4.25", with 0.125" margin on each side
// Content area: 2.5" x 4.0"
const ZINE_PAGE_WIDTH = 750; // 2.5 inches * 300 DPI (2.75" - 0.25" margins)
const ZINE_PAGE_HEIGHT = 1200; // 4.0 inches * 300 DPI (4.25" - 0.25" margins)
// Regeneration modes with denoising strengths // Regeneration modes with denoising strengths
const MODE_STRENGTHS: Record<string, number> = { const MODE_STRENGTHS: Record<string, number> = {
refine: 0.25, // Keep most of image, minor tweaks refine: 0.25, // Keep most of image, minor tweaks
@ -183,8 +189,8 @@ async function generateWithFluxImg2Img(
num_inference_steps: 28, num_inference_steps: 28,
guidance_scale: 3.5, guidance_scale: 3.5,
image_size: { image_size: {
width: 768, width: ZINE_PAGE_WIDTH,
height: 1024 // Portrait for zine pages height: ZINE_PAGE_HEIGHT // 1/8 letter page (2.75" x 4.25" at 300 DPI)
}, },
output_format: "png" output_format: "png"
}), }),