Refactor image model selection and dimensions
This commit is contained in:
@@ -1,8 +1,19 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import Together from "together-ai";
|
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(
|
async function analyzeCharacterImage(
|
||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
@@ -101,7 +112,11 @@ export async function POST(request: NextRequest) {
|
|||||||
previousContext = "",
|
previousContext = "",
|
||||||
} = await request.json();
|
} = 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) {
|
if (!prompt || !apiKey) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -157,24 +172,36 @@ export async function POST(request: NextRequest) {
|
|||||||
if (characterImages.length > 0) {
|
if (characterImages.length > 0) {
|
||||||
if (characterImages.length === 1) {
|
if (characterImages.length === 1) {
|
||||||
characterSection = `
|
characterSection = `
|
||||||
CRITICAL INSTRUCTIONS FOR CHARACTER REFERENCE:
|
CRITICAL FACE CONSISTENCY INSTRUCTIONS:
|
||||||
- Use the uploaded character image as reference for the main character
|
- REFERENCE CHARACTER: Use the uploaded image as EXACT reference for the protagonist's face and appearance
|
||||||
- This character must appear in ALL 5 panels as the protagonist
|
- FACE MATCHING: The character's face must be IDENTICAL to the reference image - same eyes, nose, mouth, hair, facial structure
|
||||||
- Maintain exact appearance from the reference image
|
- APPEARANCE PRESERVATION: Maintain exact skin tone, hair color/style, eye color, and distinctive facial features
|
||||||
- Draw them in ${style} comic art style but preserve their features, clothing, and pose from the image`;
|
- 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) {
|
} else if (characterImages.length === 2) {
|
||||||
characterSection = `
|
characterSection = `
|
||||||
CRITICAL INSTRUCTIONS FOR CHARACTER REFERENCES:
|
CRITICAL DUAL CHARACTER FACE CONSISTENCY INSTRUCTIONS:
|
||||||
- Use both uploaded character images as references for the two main characters
|
- CHARACTER 1 REFERENCE: Use the FIRST uploaded image as EXACT reference for Character 1's face and appearance
|
||||||
- CHARACTER 1 (first image) and CHARACTER 2 (second image) must appear together in at least 4 of the 5 panels
|
- CHARACTER 2 REFERENCE: Use the SECOND uploaded image as EXACT reference for Character 2's face and appearance
|
||||||
- Keep them VISUALLY DISTINCT - preserve exact appearances from their respective reference images
|
- FACE MATCHING: Both characters' faces must be IDENTICAL to their respective reference images
|
||||||
- Draw both in ${style} comic art style but maintain their individual features and clothing
|
- VISUAL DISTINCTION: Keep both characters clearly visually distinct with their unique faces, hair, and features
|
||||||
- They are the two protagonists interacting with each other throughout the story`;
|
- 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.
|
const systemPrompt = `Professional comic book page illustration.
|
||||||
${continuationContext}
|
${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):
|
TEXT AND LETTERING (CRITICAL):
|
||||||
- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
|
- All text in speech bubbles must be PERFECTLY CLEAR, LEGIBLE, and correctly spelled
|
||||||
- Use bold clean comic book lettering, large and easy to read
|
- Use bold clean comic book lettering, large and easy to read
|
||||||
@@ -209,11 +236,11 @@ COMPOSITION:
|
|||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await client.images.generate({
|
response = await client.images.generate({
|
||||||
model: "google/flash-image-2.5",
|
model: IMAGE_MODEL,
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
width: dimensions.width,
|
width: dimensions.width,
|
||||||
height: dimensions.height,
|
height: dimensions.height,
|
||||||
n: 1,
|
temperature: 0.1, // Lower temperature for more consistent face matching
|
||||||
reference_images:
|
reference_images:
|
||||||
characterImages.length > 0 ? characterImages : undefined,
|
characterImages.length > 0 ? characterImages : undefined,
|
||||||
});
|
});
|
||||||
@@ -274,7 +301,13 @@ COMPOSITION:
|
|||||||
|
|
||||||
const responseData = storyId
|
const responseData = storyId
|
||||||
? { imageUrl, pageId: page.id, pageNumber: page.pageNumber }
|
? { 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);
|
return NextResponse.json(responseData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState, useRef, useEffect } from "react"
|
|||||||
import { Upload, X, Loader2 } from "lucide-react"
|
import { Upload, X, Loader2 } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
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 = [
|
const COMIC_STYLES = [
|
||||||
{ id: "american-modern", name: "American Modern" },
|
{ id: "american-modern", name: "American Modern" },
|
||||||
@@ -47,6 +49,7 @@ export function GeneratePageModal({
|
|||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [isContinuing, setIsContinuing] = useState(false)
|
const [isContinuing, setIsContinuing] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const selectedStyle = previousPageStyle || "noir"
|
const selectedStyle = previousPageStyle || "noir"
|
||||||
|
|
||||||
@@ -66,25 +69,43 @@ export function GeneratePageModal({
|
|||||||
}
|
}
|
||||||
}, [previousCharacters])
|
}, [previousCharacters])
|
||||||
|
|
||||||
const handleFiles = (newFiles: FileList | null) => {
|
const handleFiles = async (newFiles: FileList | null) => {
|
||||||
if (!newFiles) return
|
if (!newFiles) return
|
||||||
|
|
||||||
const validFiles = Array.from(newFiles).filter((file) => file.type.startsWith("image/"))
|
const filesArray = Array.from(newFiles)
|
||||||
const totalFiles = [...uploadedFiles, ...validFiles].slice(0, 2) // Max 2 files
|
|
||||||
|
|
||||||
|
// 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)
|
setUploadedFiles(totalFiles)
|
||||||
|
|
||||||
const newPreviews: string[] = []
|
// Generate previews for all files
|
||||||
totalFiles.forEach((file, index) => {
|
const newPreviews = await Promise.all(
|
||||||
const reader = new FileReader()
|
totalFiles.map((file) => generateFilePreview(file))
|
||||||
reader.onload = (e) => {
|
)
|
||||||
newPreviews[index] = e.target?.result as string
|
setPreviews(newPreviews)
|
||||||
if (newPreviews.filter(Boolean).length === totalFiles.length) {
|
|
||||||
setPreviews([...newPreviews])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
const removeFile = (index: number) => {
|
||||||
@@ -257,7 +278,7 @@ export function GeneratePageModal({
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => handleFiles(e.target.files)}
|
onChange={(e) => handleFiles(e.target.files)}
|
||||||
|
|||||||
@@ -4,19 +4,27 @@ import type React from "react"
|
|||||||
import { useState, useRef } from "react"
|
import { useState, useRef } from "react"
|
||||||
import { X, Upload } from "lucide-react"
|
import { X, Upload } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils"
|
||||||
|
|
||||||
export function CharacterUploader() {
|
export function CharacterUploader() {
|
||||||
const [preview, setPreview] = useState<string | null>(null)
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const handleFile = (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
if (file && file.type.startsWith("image/")) {
|
const validation = validateFileForUpload(file, true)
|
||||||
const reader = new FileReader()
|
if (validation.valid) {
|
||||||
reader.onload = (e) => {
|
const previewUrl = await generateFilePreview(file)
|
||||||
setPreview(e.target?.result as string)
|
setPreview(previewUrl)
|
||||||
}
|
} else if (validation.error) {
|
||||||
reader.readAsDataURL(file)
|
toast({
|
||||||
|
title: "Invalid file",
|
||||||
|
description: validation.error,
|
||||||
|
variant: "destructive",
|
||||||
|
duration: 4000,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +32,7 @@ export function CharacterUploader() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
const file = e.dataTransfer.files[0]
|
const file = e.dataTransfer.files[0]
|
||||||
handleFile(file)
|
if (file) handleFile(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
@@ -77,7 +85,7 @@ export function CharacterUploader() {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/png,image/jpeg,image/jpg"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
resolve(e.target?.result as string)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user