feat: switch to RunPod Stable Diffusion for image generation
Gemini image generation is geo-blocked in Germany (server location). Use RunPod's Automatic1111 endpoint instead for zine page images. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d0cf3b63f
commit
6db1a2f016
|
|
@ -0,0 +1,24 @@
|
||||||
|
---
|
||||||
|
id: task-1
|
||||||
|
title: Integrate newsletter across all myco-themed websites
|
||||||
|
status: To Do
|
||||||
|
assignee: []
|
||||||
|
created_date: '2025-12-04 10:23'
|
||||||
|
labels: []
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Set up consistent newsletter integration (likely Buttondown or similar) across all myco-themed sites: mycofi.earth, mycopunk.xyz, and any related properties. Ensure unified subscriber list and consistent branding.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Newsletter service selected and configured
|
||||||
|
- [ ] #2 mycofi.earth has newsletter signup
|
||||||
|
- [ ] #3 mycopunk.xyz has newsletter signup
|
||||||
|
- [ ] #4 Subscriber lists unified or properly segmented
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
@ -11,6 +11,7 @@ services:
|
||||||
- HOSTNAME=0.0.0.0
|
- HOSTNAME=0.0.0.0
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
- GEMINI_API_KEY=${GEMINI_API_KEY}
|
||||||
|
- RUNPOD_API_KEY=${RUNPOD_API_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- zine-data:/app/data
|
- zine-data:/app/data
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
118
lib/gemini.ts
118
lib/gemini.ts
|
|
@ -14,6 +14,69 @@ function getGenAI(): GoogleGenerativeAI {
|
||||||
return _genAI;
|
return _genAI;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPod Stable Diffusion configuration
|
||||||
|
const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY;
|
||||||
|
const RUNPOD_SD_ENDPOINT = "tzf1j3sc3zufsy"; // Automatic1111 endpoint
|
||||||
|
|
||||||
|
interface RunPodResponse {
|
||||||
|
id: string;
|
||||||
|
status: "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" | "FAILED";
|
||||||
|
output?: {
|
||||||
|
images?: string[];
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateImageWithRunPod(prompt: string): Promise<string> {
|
||||||
|
if (!RUNPOD_API_KEY) {
|
||||||
|
throw new Error("RUNPOD_API_KEY environment variable is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const runUrl = `https://api.runpod.ai/v2/${RUNPOD_SD_ENDPOINT}/runsync`;
|
||||||
|
|
||||||
|
// Call RunPod Automatic1111 endpoint
|
||||||
|
const response = await fetch(runUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${RUNPOD_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: {
|
||||||
|
prompt: prompt,
|
||||||
|
negative_prompt: "blurry, low quality, distorted text, watermark, signature",
|
||||||
|
width: 512,
|
||||||
|
height: 768, // Portrait orientation for zine pages
|
||||||
|
num_inference_steps: 25,
|
||||||
|
guidance_scale: 7.5,
|
||||||
|
sampler_name: "DPM++ 2M Karras",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error("RunPod API error:", response.status, errorText);
|
||||||
|
throw new Error(`RunPod API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: RunPodResponse = await response.json();
|
||||||
|
|
||||||
|
if (result.status === "FAILED") {
|
||||||
|
throw new Error(`RunPod job failed: ${result.error || "Unknown error"}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract base64 image from response
|
||||||
|
const imageData = result.output?.images?.[0] || result.output?.image;
|
||||||
|
if (!imageData) {
|
||||||
|
throw new Error("No image data in RunPod response");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPod returns base64 without prefix
|
||||||
|
return `data:image/png;base64,${imageData}`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageOutline {
|
export interface PageOutline {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -128,55 +191,28 @@ export async function generatePageImage(
|
||||||
tone: string,
|
tone: string,
|
||||||
feedback?: string
|
feedback?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Use Nano Banana Pro for highest quality image generation
|
// Use RunPod Stable Diffusion for image generation
|
||||||
// Model: gemini-2.0-flash-exp-image-generation (supports native image output)
|
// (Gemini image gen is geo-blocked in Germany where the server is located)
|
||||||
const model = getGenAI().getGenerativeModel({
|
|
||||||
model: "gemini-2.0-flash-exp-image-generation",
|
|
||||||
generationConfig: {
|
|
||||||
// @ts-expect-error - responseModalities is valid but not in types yet
|
|
||||||
responseModalities: ["IMAGE"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const styleDesc = STYLE_PROMPTS[style] || STYLE_PROMPTS["mycelial"];
|
const styleDesc = STYLE_PROMPTS[style] || STYLE_PROMPTS["mycelial"];
|
||||||
const toneDesc = TONE_PROMPTS[tone] || TONE_PROMPTS["regenerative"];
|
const toneDesc = TONE_PROMPTS[tone] || TONE_PROMPTS["regenerative"];
|
||||||
|
|
||||||
let imagePrompt = `Create a single page for a mini-zine (approximately 825x1275 pixels aspect ratio, portrait orientation).
|
// Build a Stable Diffusion optimized prompt
|
||||||
|
let imagePrompt = `${pageOutline.title}, ${pageOutline.keyPoints.join(", ")}, ${styleDesc}, ${toneDesc}, ${pageOutline.imagePrompt}, zine page design, printable art, high quality illustration`;
|
||||||
Page ${pageOutline.pageNumber}: ${pageOutline.title}
|
|
||||||
Type: ${pageOutline.type}
|
|
||||||
Key elements: ${pageOutline.keyPoints.join(", ")}
|
|
||||||
|
|
||||||
Visual style: ${styleDesc}
|
|
||||||
Mood/tone: ${toneDesc}
|
|
||||||
|
|
||||||
Specific requirements:
|
|
||||||
${pageOutline.imagePrompt}
|
|
||||||
|
|
||||||
The image should be a complete, self-contained page that could be printed. Include any text as part of the design in a ${style} typography style.`;
|
|
||||||
|
|
||||||
if (feedback) {
|
if (feedback) {
|
||||||
imagePrompt += `\n\nUser feedback for refinement: ${feedback}`;
|
imagePrompt += `, ${feedback}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate prompt if too long (SD has token limits)
|
||||||
|
if (imagePrompt.length > 500) {
|
||||||
|
imagePrompt = imagePrompt.substring(0, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await model.generateContent(imagePrompt);
|
console.log(`Generating image for page ${pageOutline.pageNumber} with RunPod...`);
|
||||||
const response = result.response;
|
const imageDataUrl = await generateImageWithRunPod(imagePrompt);
|
||||||
|
console.log(`Successfully generated image for page ${pageOutline.pageNumber}`);
|
||||||
// Extract image from response parts
|
return imageDataUrl;
|
||||||
for (const candidate of response.candidates || []) {
|
|
||||||
for (const part of candidate.content?.parts || []) {
|
|
||||||
// @ts-expect-error - inlineData exists on image responses
|
|
||||||
if (part.inlineData) {
|
|
||||||
// @ts-expect-error - inlineData has data and mimeType
|
|
||||||
const { data, mimeType } = part.inlineData;
|
|
||||||
return `data:${mimeType || "image/png"};base64,${data}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no image in response, throw error
|
|
||||||
throw new Error("No image data in response");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Image generation error:", error);
|
console.error("Image generation error:", error);
|
||||||
throw new Error(`Failed to generate page image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
throw new Error(`Failed to generate page image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue