diff --git a/app/api/add-page/route.ts b/app/api/add-page/route.ts new file mode 100644 index 0000000..738ef6c --- /dev/null +++ b/app/api/add-page/route.ts @@ -0,0 +1,175 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import Together from "together-ai"; +import { + updatePage, + createPage, + getNextPageNumber, + getStoryWithPagesBySlug, +} from "@/lib/db-actions"; +import { freeTierRateLimit } from "@/lib/rate-limit"; +import { uploadImageToS3 } from "@/lib/s3-upload"; +import { buildComicPrompt } from "@/lib/prompt"; + +const NEW_MODEL = false; + +const IMAGE_MODEL = NEW_MODEL + ? "google/gemini-3-pro-image" + : "google/flash-image-2.5"; + +const FIXED_DIMENSIONS = NEW_MODEL + ? { width: 896, height: 1200 } + : { width: 864, height: 1184 }; + +export async function POST(request: NextRequest) { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 } + ); + } + + const { storyId, prompt, characterImages = [] } = await request.json(); + + if (!storyId || !prompt) { + return NextResponse.json( + { error: "Missing required fields: storyId and prompt" }, + { status: 400 } + ); + } + + // Get the story and all its pages + const storyData = await getStoryWithPagesBySlug(storyId); + if (!storyData) { + return NextResponse.json({ error: "Story not found" }, { status: 404 }); + } + + const { story, pages } = storyData; + + // Check ownership + if (story.userId !== userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + // Apply rate limiting for free tier + const hasApiKey = request.headers.get('x-api-key'); + if (!hasApiKey) { + const { success, reset } = await freeTierRateLimit.limit(userId); + if (!success) { + const resetDate = new Date(reset); + const timeUntilReset = Math.ceil( + (reset - Date.now()) / (1000 * 60 * 60 * 24) + ); + return NextResponse.json( + { + error: `Free tier limit reached. You can generate 1 comic per week. Try again in ${timeUntilReset} day(s), or provide your own API key.`, + resetDate: resetDate.toISOString(), + isRateLimited: true, + }, + { status: 429 } + ); + } + } + + const nextPageNumber = await getNextPageNumber(story.id); + const page = await createPage({ + storyId: story.id, + pageNumber: nextPageNumber, + prompt, + characterImageUrls: characterImages, + }); + + const dimensions = FIXED_DIMENSIONS; + + // Build the prompt with continuation context + const previousPages = pages.map(p => ({ + prompt: p.prompt, + characterImages: p.characterImageUrls, + })); + + const fullPrompt = buildComicPrompt({ + prompt, + style: story.style, + characterImages, + isAddPage: true, + previousPages, + }); + + const client = new Together({ apiKey: process.env.TOGETHER_API_KEY_DEFAULT }); + + let response; + try { + response = await client.images.generate({ + model: IMAGE_MODEL, + prompt: fullPrompt, + width: dimensions.width, + height: dimensions.height, + temperature: 0.1, + reference_images: characterImages.length > 0 ? characterImages : undefined, + }); + } catch (error) { + console.error("Together AI API error:", error); + + if (error instanceof Error && "status" in error) { + const status = (error as any).status; + if (status === 402) { + return NextResponse.json( + { + error: "Insufficient API credits.", + errorType: "credit_limit", + }, + { status: 402 } + ); + } + return NextResponse.json( + { + error: error.message || `Failed to generate image: ${status}`, + errorType: "api_error", + }, + { status: status || 500 } + ); + } + + return NextResponse.json( + { + error: `Internal server error: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + { status: 500 } + ); + } + + if (!response.data || !response.data[0] || !response.data[0].url) { + return NextResponse.json( + { error: "No image URL in response" }, + { status: 500 } + ); + } + + const imageUrl = response.data[0].url; + const s3Key = `${story.id}/page-${page.pageNumber}-${Date.now()}.jpg`; + const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key); + + await updatePage(page.id, s3ImageUrl); + + return NextResponse.json({ + imageUrl: s3ImageUrl, + pageId: page.id, + pageNumber: page.pageNumber, + }); + } catch (error) { + console.error("Error in add-page API:", error); + return NextResponse.json( + { + error: `Internal server error: ${ + error instanceof Error ? error.message : "Unknown error" + }`, + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index f775c5a..50e24fe 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -11,6 +11,7 @@ import { import { freeTierRateLimit } from "@/lib/rate-limit"; import { COMIC_STYLES } from "@/lib/constants"; import { uploadImageToS3 } from "@/lib/s3-upload"; +import { buildComicPrompt } from "@/lib/prompt"; const NEW_MODEL = false; @@ -119,74 +120,14 @@ export async function POST(request: NextRequest) { } const dimensions = FIXED_DIMENSIONS; - const styleInfo = COMIC_STYLES.find((s) => s.id === style); - const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt; - const continuationContext = - isContinuation && previousContext - ? `\nCONTINUATION CONTEXT:\nThis is a continuation of an existing story. The previous page showed: ${previousContext}\nMaintain visual consistency with the previous panels. Continue the narrative naturally.\n` - : ""; - - let characterSection = ""; - if (characterImages.length > 0) { - if (characterImages.length === 1) { - characterSection = ` -CRITICAL FACE CONSISTENCY INSTRUCTIONS: -- REFERENCE CHARACTER: Use the uploaded image as EXACT reference for the protagonist's face and appearance -- FACE MATCHING: The character's face must be IDENTICAL to the reference image - same eyes, nose, mouth, hair, facial structure -- APPEARANCE PRESERVATION: Maintain exact skin tone, hair color/style, eye color, and distinctive facial features -- CHARACTER CONSISTENCY: This exact same character must appear in ALL 5 panels with the same face throughout -- STYLE APPLICATION: Apply ${style} comic art style to the body/pose/action but KEEP THE FACE EXACTLY AS IN THE REFERENCE IMAGE -- NO VARIATION: Do not alter, modify, or change the character's face in any way from the reference`; - } else if (characterImages.length === 2) { - characterSection = ` -CRITICAL DUAL CHARACTER FACE CONSISTENCY INSTRUCTIONS: -- CHARACTER 1 REFERENCE: Use the FIRST uploaded image as EXACT reference for Character 1's face and appearance -- CHARACTER 2 REFERENCE: Use the SECOND uploaded image as EXACT reference for Character 2's face and appearance -- FACE MATCHING: Both characters' faces must be IDENTICAL to their respective reference images -- VISUAL DISTINCTION: Keep both characters clearly visually distinct with their unique faces, hair, and features -- CONSISTENT PRESENCE: Both characters must appear together in at least 4 of the 5 panels -- STYLE APPLICATION: Apply ${style} comic art style while maintaining EXACT facial features from references -- NO FACE VARIATION: Never alter or modify either character's face from their reference images`; - } - } - - const systemPrompt = `Professional comic book page illustration. -${continuationContext} -${characterSection} - -CHARACTER CONSISTENCY RULES (HIGHEST PRIORITY): -- If reference images are provided, the characters' FACES must be 100% identical to the reference images -- Never change hair color, eye color, facial structure, or distinctive features -- Apply comic style to body/pose/action but preserve exact facial appearance -- Same character must look identical across all panels they appear in - -TEXT AND LETTERING (CRITICAL): -- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled -- Use bold clean comic book lettering, large and easy to read -- Speech bubbles: crisp white fill, solid black outline, pointed tail toward speaker -- Keep dialogue SHORT: maximum 1-2 sentences per bubble -- NO blurry, warped, or unreadable text - -PAGE LAYOUT: -5-panel comic page arranged as: -[Panel 1] [Panel 2] — top row, 2 equal panels -[ Panel 3 ] — middle row, 1 large cinematic hero panel -[Panel 4] [Panel 5] — bottom row, 2 equal panels -- Solid black panel borders with clean white gutters between panels -- Each panel clearly separated and distinct - -ART STYLE: -${styleDesc} -${characterSection} - -COMPOSITION: -- Vary camera angles across panels: close-up, medium shot, wide establishing shot -- Natural visual flow: left-to-right, top-to-bottom reading order -- Dynamic character poses with clear expressive acting -- Detailed backgrounds matching the scene and mood`; - - const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`; + const fullPrompt = buildComicPrompt({ + prompt, + style, + characterImages, + isContinuation, + previousContext, + }); const client = new Together({ apiKey: finalApiKey }); diff --git a/app/editor/[storySlug]/page.tsx b/app/editor/[storySlug]/page.tsx index 8551d40..877bf6e 100644 --- a/app/editor/[storySlug]/page.tsx +++ b/app/editor/[storySlug]/page.tsx @@ -22,8 +22,10 @@ interface PageData { interface StoryData { id: string; + slug: string; title: string; description?: string | null; + style: string; userId?: string | null; } @@ -134,10 +136,8 @@ export default function StoryEditorPage() { const handleGeneratePage = async (data: { prompt: string; - style: string; characterFiles?: File[]; characterUrls?: string[]; - isContinuation?: boolean; }) => { try { const apiKey = localStorage.getItem("together_api_key"); @@ -146,23 +146,22 @@ export default function StoryEditorPage() { return; } - const response = await fetch("/api/generate-comic", { + const response = await fetch("/api/add-page", { method: "POST", headers: { "Content-Type": "application/json", + "x-api-key": apiKey, }, body: JSON.stringify({ - storyId: story?.id, + storyId: story?.slug, prompt: data.prompt, - apiKey, - style: data.style, characterImages: data.characterUrls || [], }), }); if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || "Failed to generate image"); + throw new Error(errorData.error || "Failed to generate page"); } const result = await response.json(); @@ -175,7 +174,7 @@ export default function StoryEditorPage() { image: result.imageUrl, prompt: data.prompt, characterUploads: data.characterUrls || [], - style: data.style, + style: story?.style || "noir", }, ]); setCurrentPage(pages.length); @@ -183,7 +182,7 @@ export default function StoryEditorPage() { } catch (error) { console.error("Error generating page:", error); toast({ - title: "Generation failed", + title: "Failed to generate page", description: error instanceof Error ? error.message : "Failed to generate page", variant: "destructive", @@ -242,13 +241,6 @@ export default function StoryEditorPage() { onClose={() => setShowGenerateModal(false)} onGenerate={handleGeneratePage} pageNumber={pages.length + 1} - allPages={pages.map((page, index) => ({ - pageNumber: page.id, - characterImages: page.characterUploads || [], - prompt: page.prompt, - imageUrl: page.image, - style: page.style, - }))} /> void; onGenerate: (data: { prompt: string; - style: string; characterFiles?: File[]; characterUrls?: string[]; - isContinuation?: boolean; }) => void; pageNumber: number; - allPages: PageReference[]; } export function GeneratePageModal({ @@ -40,38 +29,25 @@ export function GeneratePageModal({ onClose, onGenerate, pageNumber, - allPages, }: GeneratePageModalProps) { const [prompt, setPrompt] = useState(""); const [uploadedFiles, setUploadedFiles] = useState([]); - const [selectedExistingCharacters, setSelectedExistingCharacters] = useState< - string[] - >([]); - const [referencePageNumber, setReferencePageNumber] = useState( - allPages.length > 0 ? allPages.length : 1 - ); - const [pageImage, setPageImage] = useState(null); const [previews, setPreviews] = useState([]); const [showPreview, setShowPreview] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const fileInputRef = useRef(null); const { toast } = useToast(); - const referencePage = - allPages.find((p) => p.pageNumber === referencePageNumber) || null; - const referenceStyleId = referencePage?.style || "noir"; - const selectedStyleName = - COMIC_STYLES.find((s) => s.id === referenceStyleId)?.name || "Noir"; - const selectedStyle = useMemo(() => selectedStyleName, [referenceStyleId]); - + // Reset form when modal opens useEffect(() => { - if (isOpen && referencePage) { - setSelectedExistingCharacters(referencePage.characterImages); - setPageImage(referencePage.imageUrl || null); - } else if (isOpen && !referencePage) { - setPageImage(null); + if (isOpen) { + setPrompt(""); + setUploadedFiles([]); + setPreviews([]); + setShowPreview(null); + setIsGenerating(false); } - }, [isOpen, referencePage]); + }, [isOpen]); const handleFiles = async (newFiles: FileList | null) => { if (!newFiles) return; @@ -120,26 +96,12 @@ export function GeneratePageModal({ } }; - const toggleExistingCharacter = (characterUrl: string) => { - setSelectedExistingCharacters((prev) => - prev.includes(characterUrl) - ? prev.filter((url) => url !== characterUrl) - : [...prev, characterUrl] - ); - }; - - const togglePageImage = () => { - setPageImage((prev) => - prev === null && referencePage?.imageUrl ? referencePage.imageUrl : null - ); - }; - const handleGenerate = async () => { if (!prompt.trim()) return; setIsGenerating(true); const fileDataUrls = await Promise.all( - uploadedFiles.map((file, index) => { + uploadedFiles.map((file) => { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { @@ -153,33 +115,14 @@ export function GeneratePageModal({ }) ); - const allCharacterUrls: string[] = [ - ...selectedExistingCharacters, - ...fileDataUrls, - ...(pageImage ? [pageImage] : []), - ]; - onGenerate({ prompt, - style: selectedStyle, characterFiles: uploadedFiles.length > 0 ? uploadedFiles : undefined, - characterUrls: allCharacterUrls, - isContinuation: false, + characterUrls: fileDataUrls, }); }; - useEffect(() => { - if (!isOpen) { - setIsGenerating(false); - setPrompt(""); - setUploadedFiles([]); - setSelectedExistingCharacters([]); - setPageImage(null); - if (allPages.length > 0) { - setReferencePageNumber(allPages.length); - } - } - }, [isOpen]); + return ( <> @@ -192,93 +135,28 @@ export function GeneratePageModal({
- {/* Reference Page Selection */} - {allPages.length > 0 && ( -
- - -
- )} - + {/* Prompt Input */}
-
- {selectedStyleName} -