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 { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
import { usePinnedToView } from "@/hooks/usePinnedToView" import { usePinnedToView } from "@/hooks/usePinnedToView"
import { useMaximize } from "@/hooks/useMaximize" 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 // Types
@ -262,7 +268,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
return newMessage return newMessage
} }
// Generate content outline from ideation // Generate content outline from ideation using AI API
const generateOutline = async () => { const generateOutline = async () => {
if (!shape.props.topic) { if (!shape.props.topic) {
updateProps({ error: 'Please enter a topic first' }) updateProps({ error: 'Please enter a topic first' })
@ -272,26 +278,58 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
updateProps({ isLoading: true, error: null }) updateProps({ isLoading: true, error: null })
try { try {
// Build outline based on topic and conversation // Call the standalone zine API for AI-generated outline
const outline: ZinePageOutline[] = PAGE_TEMPLATES.map((template, index) => ({ const response = await fetch(`${ZINE_API_BASE}/api/outline`, {
pageNumber: index + 1, method: 'POST',
type: template.type as 'cover' | 'content' | 'cta', headers: { 'Content-Type': 'application/json' },
title: index === 0 ? shape.props.topic.toUpperCase() : `Page ${index + 1}`, body: JSON.stringify({
subtitle: template.description, topic: shape.props.topic,
keyPoints: [], 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: [], 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 // 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({ updateProps({
zineId: newZineId,
contentOutline: outline, contentOutline: outline,
title: shape.props.topic.toUpperCase(), title: outline[0]?.title || shape.props.topic.toUpperCase(),
isLoading: false, isLoading: false,
}) })
} catch (err) { } catch (err) {
console.error('Outline generation error:', err)
updateProps({ updateProps({
isLoading: false, isLoading: false,
error: `Failed to generate outline: ${err instanceof Error ? err.message : String(err)}`, 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 () => { const generateDrafts = async () => {
if (!shape.props.contentOutline) { if (!shape.props.contentOutline) {
await generateOutline() await generateOutline()
@ -338,6 +376,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
}) })
const outline = shape.props.contentOutline! const outline = shape.props.contentOutline!
const zineId = shape.props.zineId
const generatedPages: GeneratedZinePage[] = [] const generatedPages: GeneratedZinePage[] = []
for (let i = 0; i < outline.length; i++) { for (let i = 0; i < outline.length; i++) {
@ -345,19 +384,34 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
updateProps({ currentGeneratingPage: i + 1 }) updateProps({ currentGeneratingPage: i + 1 })
try { try {
// Build the image generation prompt // Call the standalone zine API for page generation
const prompt = buildImagePrompt(pageOutline, shape.props.topic, shape.props.style, shape.props.tone) 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 if (!response.ok) {
// Note: In actual implementation, this would call the MCP server const errorData = await response.json().catch(() => ({})) as { error?: string }
// For now, we'll use a placeholder approach throw new Error(errorData.error || `API error: ${response.status}`)
const imageUrl = await generatePageImage(prompt, i + 1) }
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({ generatedPages.push({
pageNumber: i + 1, pageNumber: i + 1,
imageUrl, imageUrl,
outline: pageOutline, outline: pageOutline,
generationPrompt: prompt, generationPrompt: pageOutline.imagePrompt,
timestamp: Date.now(), timestamp: Date.now(),
version: 1, version: 1,
}) })
@ -374,6 +428,7 @@ export class MycroZineGeneratorShape extends BaseBoxShapeUtil<IMycroZineGenerato
updateProps({ updateProps({
error: `Failed to generate page ${i + 1}: ${err instanceof Error ? err.message : String(err)}`, 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 f => f.approved || f.feedbackText.length > 0
) )
// Move to finalization // Move to finalization - regenerate pages with feedback using API
const startFinalization = async () => { const startFinalization = async () => {
const pagesNeedingUpdate = shape.props.pageFeedback.filter(f => !f.approved && f.feedbackText) 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 finalPages = [...shape.props.draftPages]
const zineId = shape.props.zineId
for (const feedback of pagesNeedingUpdate) { for (const feedback of pagesNeedingUpdate) {
updateProps({ currentGeneratingPage: feedback.pageNumber }) 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) const originalPage = shape.props.draftPages.find(p => p.pageNumber === feedback.pageNumber)
if (!originalPage) continue if (!originalPage) continue
// Regenerate with feedback incorporated // Call the standalone API to regenerate with feedback
const prompt = `${originalPage.generationPrompt}\n\nIMPORTANT REVISIONS: ${feedback.feedbackText}` const response = await fetch(`${ZINE_API_BASE}/api/regenerate-page`, {
const imageUrl = await generatePageImage(prompt, feedback.pageNumber) 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] = { finalPages[feedback.pageNumber - 1] = {
...originalPage, ...originalPage,
imageUrl, imageUrl,
generationPrompt: prompt, outline: data.updatedOutline || originalPage.outline,
generationPrompt: data.updatedOutline?.imagePrompt || originalPage.generationPrompt,
timestamp: Date.now(), timestamp: Date.now(),
version: originalPage.version + 1, version: originalPage.version + 1,
} }
} catch (err) { } catch (err) {
console.error(`Failed to regenerate page ${feedback.pageNumber}:`, 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 () => { const generatePrintLayout = async () => {
updateProps({ isLoading: true, error: null }) updateProps({ isLoading: true, error: null })
try { try {
// In actual implementation, this would call the mycro-zine layout generator const zineId = shape.props.zineId
// For now, show a success message const zineName = (shape.props.title || shape.props.topic).slice(0, 20).replace(/[^a-zA-Z0-9]/g, '_')
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const printUrl = `/output/${shape.props.zineId}_print_${timestamp}.png` // 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({ updateProps({
printLayoutUrl: printUrl, printLayoutUrl: printUrl,
isLoading: false, 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) { } catch (err) {
console.error('Print layout error:', err)
updateProps({ updateProps({
isLoading: false, isLoading: false,
error: `Failed to generate print layout: ${err instanceof Error ? err.message : String(err)}`, 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 // Helper Functions
// ============================================================================ // ============================================================================
function buildImagePrompt( // Spawn page images on the canvas in a 4x2 grid
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
async function spawnPageOnCanvas( async function spawnPageOnCanvas(
editor: any, editor: any,
imageUrl: string, imageUrl: string,