feat: upgrade MycroZine generator to use full standalone API

- Use /api/outline for AI-generated 8-page outlines via Gemini
- Use /api/generate-page for individual page image generation
- Use /api/regenerate-page for page regeneration with feedback
- Use /api/print-layout for 300 DPI print-ready layout generation
- Remove legacy local generation functions
- Add proper error handling and API response parsing
- Include folding instructions in completion message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-22 08:58:22 -05:00
parent 3cda68370e
commit 7feea26188
1 changed files with 136 additions and 108 deletions

View File

@ -11,7 +11,13 @@ import React, { useState, useRef, useEffect } from "react"
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
import { usePinnedToView } from "@/hooks/usePinnedToView"
import { useMaximize } from "@/hooks/useMaximize"
// Note: Image generation now uses zine.jeffemmett.com API which proxies through RunPod
// Uses zine.jeffemmett.com API for full zine generation workflow:
// - /api/outline - AI-generated 8-page outlines via Gemini
// - /api/generate-page - Individual page image generation via RunPod
// - /api/regenerate-page - Page regeneration with feedback
// - /api/print-layout - 300 DPI print-ready layout generation
const ZINE_API_BASE = 'https://zine.jeffemmett.com'
// ============================================================================
// Types
@ -262,7 +268,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
return newMessage
}
// Generate content outline from ideation
// Generate content outline from ideation using AI API
const generateOutline = async () => {
if (!shape.props.topic) {
updateProps({ error: 'Please enter a topic first' })
@ -272,26 +278,58 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
updateProps({ isLoading: true, error: null })
try {
// Build outline based on topic and conversation
const outline: ZinePageOutline[] = PAGE_TEMPLATES.map((template, index) => ({
pageNumber: index + 1,
type: template.type as 'cover' | 'content' | 'cta',
title: index === 0 ? shape.props.topic.toUpperCase() : `Page ${index + 1}`,
subtitle: template.description,
keyPoints: [],
// Call the standalone zine API for AI-generated outline
const response = await fetch(`${ZINE_API_BASE}/api/outline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: shape.props.topic,
style: shape.props.style,
tone: shape.props.tone,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { error?: string }
throw new Error(errorData.error || `API error: ${response.status}`)
}
const data = await response.json() as {
id: string
outline: Array<{
pageNumber: number
type: string
title: string
keyPoints: string[]
imagePrompt: string
}>
}
// Convert API response to our outline format
const outline: ZinePageOutline[] = data.outline.map((page) => ({
pageNumber: page.pageNumber,
type: page.type as 'cover' | 'content' | 'cta',
title: page.title,
subtitle: undefined,
keyPoints: page.keyPoints,
hashtags: [],
imagePrompt: `${STYLES[shape.props.style]}. ${TONES[shape.props.tone]}. ${template.description} for ${shape.props.topic}.`,
imagePrompt: page.imagePrompt,
}))
// Store the zine ID from the API for later use
const newZineId = data.id
// Add assistant message with outline summary
addMessage('assistant', `Great! I've created an outline for your "${shape.props.topic}" zine:\n\n${outline.map(p => `Page ${p.pageNumber}: ${p.title}`).join('\n')}\n\nClick "Generate Drafts" when you're ready to create the pages!`)
addMessage('assistant', `Great! I've created an AI-generated outline for your "${shape.props.topic}" zine:\n\n${outline.map(p => `Page ${p.pageNumber}: ${p.title}\n • ${p.keyPoints.slice(0, 2).join('\n • ')}`).join('\n\n')}\n\nClick "Generate Drafts" when you're ready to create the pages!`)
updateProps({
zineId: newZineId,
contentOutline: outline,
title: shape.props.topic.toUpperCase(),
title: outline[0]?.title || shape.props.topic.toUpperCase(),
isLoading: false,
})
} catch (err) {
console.error('Outline generation error:', err)
updateProps({
isLoading: false,
error: `Failed to generate outline: ${err instanceof Error ? err.message : String(err)}`,
@ -323,7 +361,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
}
}
// Generate draft pages using Gemini
// Generate draft pages using the standalone zine API
const generateDrafts = async () => {
if (!shape.props.contentOutline) {
await generateOutline()
@ -338,6 +376,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
})
const outline = shape.props.contentOutline!
const zineId = shape.props.zineId
const generatedPages: GeneratedZinePage[] = []
for (let i = 0; i < outline.length; i++) {
@ -345,19 +384,34 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
updateProps({ currentGeneratingPage: i + 1 })
try {
// Build the image generation prompt
const prompt = buildImagePrompt(pageOutline, shape.props.topic, shape.props.style, shape.props.tone)
// Call the standalone zine API for page generation
const response = await fetch(`${ZINE_API_BASE}/api/generate-page`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zineId: zineId,
pageNumber: i + 1,
outline: pageOutline,
style: shape.props.style,
tone: shape.props.tone,
}),
})
// Call Gemini MCP for image generation
// Note: In actual implementation, this would call the MCP server
// For now, we'll use a placeholder approach
const imageUrl = await generatePageImage(prompt, i + 1)
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { error?: string }
throw new Error(errorData.error || `API error: ${response.status}`)
}
const data = await response.json() as { pageNumber: number; imageUrl: string; success: boolean }
// Use the API-returned image URL
const imageUrl = `${ZINE_API_BASE}${data.imageUrl}`
generatedPages.push({
pageNumber: i + 1,
imageUrl,
outline: pageOutline,
generationPrompt: prompt,
generationPrompt: pageOutline.imagePrompt,
timestamp: Date.now(),
version: 1,
})
@ -374,6 +428,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
updateProps({
error: `Failed to generate page ${i + 1}: ${err instanceof Error ? err.message : String(err)}`,
})
// Continue with remaining pages even if one fails
}
}
@ -418,7 +473,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
f => f.approved || f.feedbackText.length > 0
)
// Move to finalization
// Move to finalization - regenerate pages with feedback using API
const startFinalization = async () => {
const pagesNeedingUpdate = shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText)
@ -438,6 +493,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
})
const finalPages = [...shape.props.draftPages]
const zineId = shape.props.zineId
for (const feedback of pagesNeedingUpdate) {
updateProps({ currentGeneratingPage: feedback.pageNumber })
@ -446,19 +502,45 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
const originalPage = shape.props.draftPages.find(p => p.pageNumber === feedback.pageNumber)
if (!originalPage) continue
// Regenerate with feedback incorporated
const prompt = `${originalPage.generationPrompt}\n\nIMPORTANT REVISIONS: ${feedback.feedbackText}`
const imageUrl = await generatePageImage(prompt, feedback.pageNumber)
// Call the standalone API to regenerate with feedback
const response = await fetch(`${ZINE_API_BASE}/api/regenerate-page`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zineId: zineId,
pageNumber: feedback.pageNumber,
currentOutline: originalPage.outline,
feedback: feedback.feedbackText,
style: shape.props.style,
tone: shape.props.tone,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { error?: string }
throw new Error(errorData.error || `API error: ${response.status}`)
}
const data = await response.json() as {
imageUrl: string
updatedOutline: ZinePageOutline
}
const imageUrl = `${ZINE_API_BASE}${data.imageUrl}`
finalPages[feedback.pageNumber - 1] = {
...originalPage,
imageUrl,
generationPrompt: prompt,
outline: data.updatedOutline || originalPage.outline,
generationPrompt: data.updatedOutline?.imagePrompt || originalPage.generationPrompt,
timestamp: Date.now(),
version: originalPage.version + 1,
}
} catch (err) {
console.error(`Failed to regenerate page ${feedback.pageNumber}:`, err)
updateProps({
error: `Failed to regenerate page ${feedback.pageNumber}: ${err instanceof Error ? err.message : String(err)}`,
})
}
}
@ -470,23 +552,45 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
})
}
// Generate print layout
// Generate print layout using the standalone API (300 DPI, proper folding order)
const generatePrintLayout = async () => {
updateProps({ isLoading: true, error: null })
try {
// In actual implementation, this would call the mycro-zine layout generator
// For now, show a success message
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const printUrl = `/output/${shape.props.zineId}_print_${timestamp}.png`
const zineId = shape.props.zineId
const zineName = (shape.props.title || shape.props.topic).slice(0, 20).replace(/[^a-zA-Z0-9]/g, '_')
// Call the standalone API for print layout generation
const response = await fetch(`${ZINE_API_BASE}/api/print-layout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zineId: zineId,
zineName: zineName,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({})) as { error?: string }
throw new Error(errorData.error || `API error: ${response.status}`)
}
const data = await response.json() as {
success: boolean
printLayoutUrl: string
filename: string
}
const printUrl = `${ZINE_API_BASE}${data.printLayoutUrl}`
updateProps({
printLayoutUrl: printUrl,
isLoading: false,
})
addMessage('assistant', `Print layout generated! Your zine is ready to download and print.`)
addMessage('assistant', `🖨️ Print layout generated at 300 DPI!\n\nDownload and print on 8.5" × 11" paper (landscape).\n\nFolding instructions:\n1. Fold in half lengthwise (hotdog fold)\n2. Fold in half again\n3. Fold once more to create booklet\n4. Unfold and cut center slit\n5. Refold and push ends together\n6. Pages should now be in order 1-8!`)
} catch (err) {
console.error('Print layout error:', err)
updateProps({
isLoading: false,
error: `Failed to generate print layout: ${err instanceof Error ? err.message : String(err)}`,
@ -1035,83 +1139,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
// Helper Functions
// ============================================================================
function buildImagePrompt(
pageOutline: ZinePageOutline,
topic: string,
style: ZineStyle,
tone: ZineTone
): string {
const styleDesc = STYLES[style]
const toneDesc = TONES[tone]
return `Punk zine page ${pageOutline.pageNumber}/8 for "${topic}".
${pageOutline.imagePrompt}
Title text: "${pageOutline.title}"
${pageOutline.subtitle ? `Subtitle: "${pageOutline.subtitle}"` : ''}
${pageOutline.keyPoints.length > 0 ? `Key points: ${pageOutline.keyPoints.join(', ')}` : ''}
${pageOutline.hashtags.length > 0 ? `Hashtags: ${pageOutline.hashtags.join(' ')}` : ''}
Style: ${styleDesc}
Tone: ${toneDesc}
High contrast black and white with neon green accent highlights. Xerox texture, DIY cut-and-paste collage aesthetic, rough edges, rebellious feel.`
}
interface GenerateImageResponse {
success: boolean
imageData?: string
mimeType?: string
error?: string
}
async function generatePageImage(prompt: string, pageNumber: number): Promise<string> {
console.log(`🍄 Generating page ${pageNumber} via RunPod proxy...`)
console.log(`📝 Prompt preview:`, prompt.substring(0, 100) + '...')
// Use the mycro-zine API which proxies through RunPod (US-based, bypasses geo-restrictions)
const ZINE_API_URL = 'https://zine.jeffemmett.com/api/generate-image'
try {
const response = await fetch(ZINE_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ prompt }),
})
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`
try {
const errorData = await response.json() as { error?: string }
if (errorData.error) errorMessage = errorData.error
} catch {
// Ignore JSON parse errors
}
console.error(`❌ API error for page ${pageNumber}:`, response.status, errorMessage)
throw new Error(errorMessage)
}
const data: GenerateImageResponse = await response.json()
if (data.success && data.imageData) {
console.log(`✅ Page ${pageNumber} generated via RunPod proxy`)
// Convert base64 to data URL
return `data:${data.mimeType || 'image/png'};base64,${data.imageData}`
}
throw new Error('No image data in response')
} catch (error) {
console.error(`❌ Generation failed for page ${pageNumber}:`, error)
// Fallback to placeholder
return `https://via.placeholder.com/825x1275/1a1a1a/00ff00?text=Page+${pageNumber}+%28Generation+Failed%29`
}
}
// Removed direct Gemini API functions - now using zine.jeffemmett.com proxy
// Spawn page images on the canvas in a 4x2 grid
async function spawnPageOnCanvas(
editor: any,
imageUrl: string,