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:
parent
3cda68370e
commit
7feea26188
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue