From bbe8c8caf8fe4b3b6ea79272b4eecc47e7fb1ac7 Mon Sep 17 00:00:00 2001 From: Riccardo Giorato Date: Tue, 23 Dec 2025 21:41:38 +0100 Subject: [PATCH] Refactor image model selection and dimensions --- app/api/generate-comic/route.ts | 67 +++++++++++++++++------ components/editor/generate-page-modal.tsx | 51 ++++++++++++----- components/landing/character-uploader.tsx | 26 ++++++--- lib/file-utils.ts | 29 ++++++++++ 4 files changed, 132 insertions(+), 41 deletions(-) create mode 100644 lib/file-utils.ts diff --git a/app/api/generate-comic/route.ts b/app/api/generate-comic/route.ts index 4e64068..0394f28 100644 --- a/app/api/generate-comic/route.ts +++ b/app/api/generate-comic/route.ts @@ -1,8 +1,19 @@ import { type NextRequest, NextResponse } from "next/server"; import Together from "together-ai"; -import { updatePage, createStory, createPage, getNextPageNumber } from "@/lib/db-actions"; +import { + updatePage, + createStory, + createPage, + getNextPageNumber, +} from "@/lib/db-actions"; -const FIXED_DIMENSIONS = { width: 864, height: 1184 }; +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 }; async function analyzeCharacterImage( imageBase64: string, @@ -101,7 +112,11 @@ export async function POST(request: NextRequest) { previousContext = "", } = await request.json(); - console.log("Received request:", { storyId, prompt: prompt?.substring(0, 50), characterImagesCount: characterImages.length }); + console.log("Received request:", { + storyId, + prompt: prompt?.substring(0, 50), + characterImagesCount: characterImages.length, + }); if (!prompt || !apiKey) { return NextResponse.json( @@ -157,24 +172,36 @@ export async function POST(request: NextRequest) { if (characterImages.length > 0) { if (characterImages.length === 1) { characterSection = ` -CRITICAL INSTRUCTIONS FOR CHARACTER REFERENCE: -- Use the uploaded character image as reference for the main character -- This character must appear in ALL 5 panels as the protagonist -- Maintain exact appearance from the reference image -- Draw them in ${style} comic art style but preserve their features, clothing, and pose from the image`; +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 INSTRUCTIONS FOR CHARACTER REFERENCES: -- Use both uploaded character images as references for the two main characters -- CHARACTER 1 (first image) and CHARACTER 2 (second image) must appear together in at least 4 of the 5 panels -- Keep them VISUALLY DISTINCT - preserve exact appearances from their respective reference images -- Draw both in ${style} comic art style but maintain their individual features and clothing -- They are the two protagonists interacting with each other throughout the story`; +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 @@ -209,11 +236,11 @@ COMPOSITION: let response; try { response = await client.images.generate({ - model: "google/flash-image-2.5", + model: IMAGE_MODEL, prompt: fullPrompt, width: dimensions.width, height: dimensions.height, - n: 1, + temperature: 0.1, // Lower temperature for more consistent face matching reference_images: characterImages.length > 0 ? characterImages : undefined, }); @@ -274,7 +301,13 @@ COMPOSITION: const responseData = storyId ? { imageUrl, pageId: page.id, pageNumber: page.pageNumber } - : { imageUrl, storyId: story!.id, storySlug: story!.slug, pageId: page.id, pageNumber: page.pageNumber }; + : { + imageUrl, + storyId: story!.id, + storySlug: story!.slug, + pageId: page.id, + pageNumber: page.pageNumber, + }; return NextResponse.json(responseData); } catch (error) { diff --git a/components/editor/generate-page-modal.tsx b/components/editor/generate-page-modal.tsx index b3e32b1..765994a 100644 --- a/components/editor/generate-page-modal.tsx +++ b/components/editor/generate-page-modal.tsx @@ -4,6 +4,8 @@ import { useState, useRef, useEffect } from "react" import { Upload, X, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { useToast } from "@/hooks/use-toast" +import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils" const COMIC_STYLES = [ { id: "american-modern", name: "American Modern" }, @@ -47,6 +49,7 @@ export function GeneratePageModal({ const [isGenerating, setIsGenerating] = useState(false) const [isContinuing, setIsContinuing] = useState(false) const fileInputRef = useRef(null) + const { toast } = useToast() const selectedStyle = previousPageStyle || "noir" @@ -66,25 +69,43 @@ export function GeneratePageModal({ } }, [previousCharacters]) - const handleFiles = (newFiles: FileList | null) => { + const handleFiles = async (newFiles: FileList | null) => { if (!newFiles) return - const validFiles = Array.from(newFiles).filter((file) => file.type.startsWith("image/")) - const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files + const filesArray = Array.from(newFiles) + // Validate files (including WebP rejection) + const validationResults = filesArray.map(file => ({ + file, + validation: validateFileForUpload(file, true) + })) + + // Show errors for invalid files + validationResults.forEach(({ validation }) => { + if (!validation.valid && validation.error) { + toast({ + title: "Invalid file", + description: validation.error, + variant: "destructive", + duration: 4000, + }) + } + }) + + const validFiles = validationResults + .filter(({ validation }) => validation.valid) + .map(({ file }) => file) + + if (validFiles.length === 0) return + + const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files setUploadedFiles(totalFiles) - const newPreviews: string[] = [] - totalFiles.forEach((file, index) => { - const reader = new FileReader() - reader.onload = (e) => { - newPreviews[index] = e.target?.result as string - if (newPreviews.filter(Boolean).length === totalFiles.length) { - setPreviews([...newPreviews]) - } - } - reader.readAsDataURL(file) - }) + // Generate previews for all files + const newPreviews = await Promise.all( + totalFiles.map((file) => generateFilePreview(file)) + ) + setPreviews(newPreviews) } const removeFile = (index: number) => { @@ -257,7 +278,7 @@ export function GeneratePageModal({ handleFiles(e.target.files)} diff --git a/components/landing/character-uploader.tsx b/components/landing/character-uploader.tsx index 95cd33b..10a5ca3 100644 --- a/components/landing/character-uploader.tsx +++ b/components/landing/character-uploader.tsx @@ -4,19 +4,27 @@ import type React from "react" import { useState, useRef } from "react" import { X, Upload } from "lucide-react" import { Button } from "@/components/ui/button" +import { useToast } from "@/hooks/use-toast" +import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils" export function CharacterUploader() { const [preview, setPreview] = useState(null) const [isDragging, setIsDragging] = useState(false) const fileInputRef = useRef(null) + const { toast } = useToast() - const handleFile = (file: File) => { - if (file && file.type.startsWith("image/")) { - const reader = new FileReader() - reader.onload = (e) => { - setPreview(e.target?.result as string) - } - reader.readAsDataURL(file) + const handleFile = async (file: File) => { + const validation = validateFileForUpload(file, true) + if (validation.valid) { + const previewUrl = await generateFilePreview(file) + setPreview(previewUrl) + } else if (validation.error) { + toast({ + title: "Invalid file", + description: validation.error, + variant: "destructive", + duration: 4000, + }) } } @@ -24,7 +32,7 @@ export function CharacterUploader() { e.preventDefault() setIsDragging(false) const file = e.dataTransfer.files[0] - handleFile(file) + if (file) handleFile(file) } const handleDragOver = (e: React.DragEvent) => { @@ -77,7 +85,7 @@ export function CharacterUploader() { { const file = e.target.files?.[0] diff --git a/lib/file-utils.ts b/lib/file-utils.ts new file mode 100644 index 0000000..028a138 --- /dev/null +++ b/lib/file-utils.ts @@ -0,0 +1,29 @@ +export function validateFileForUpload(file: File, rejectWebP: boolean = true): { valid: boolean; error?: string } { + // Reject WebP files + if (rejectWebP && (file.type === "image/webp" || file.name.toLowerCase().endsWith('.webp'))) { + return { + valid: false, + error: "WebP files not supported. Please upload PNG, JPG, or JPEG files instead of WebP." + } + } + + // Check if file is an image + if (!file.type.startsWith("image/")) { + return { + valid: false, + error: "Only image files are supported." + } + } + + return { valid: true } +} + +export function generateFilePreview(file: File): Promise { + return new Promise((resolve) => { + const reader = new FileReader() + reader.onload = (e) => { + resolve(e.target?.result as string) + } + reader.readAsDataURL(file) + }) +} \ No newline at end of file