Refactor comic page generation and add add-page API
Introduces a new /api/add-page endpoint for adding pages to existing comics, refactors prompt construction into a shared utility (lib/prompt.ts), and updates the editor and modal components to use the new API and prompt builder. Removes manual style and character selection in the modal, streamlining the page generation workflow and ensuring previous story context is referenced automatically.
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
<PageInfoSheet
|
||||
isOpen={showInfoSheet}
|
||||
|
||||
@@ -13,26 +13,15 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { validateFileForUpload, generateFilePreview } from "@/lib/file-utils";
|
||||
import { COMIC_STYLES } from "@/lib/constants";
|
||||
|
||||
interface PageReference {
|
||||
pageNumber: number;
|
||||
characterImages: string[];
|
||||
prompt: string;
|
||||
imageUrl?: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
interface GeneratePageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => 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<File[]>([]);
|
||||
const [selectedExistingCharacters, setSelectedExistingCharacters] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [referencePageNumber, setReferencePageNumber] = useState<number>(
|
||||
allPages.length > 0 ? allPages.length : 1
|
||||
);
|
||||
const [pageImage, setPageImage] = useState<string | null>(null);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
const [showPreview, setShowPreview] = useState<number | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<string>((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({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Reference Page Selection */}
|
||||
{allPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground uppercase tracking-[0.02em] font-medium">
|
||||
Reference Page
|
||||
</label>
|
||||
<select
|
||||
value={referencePageNumber}
|
||||
onChange={(e) =>
|
||||
setReferencePageNumber(Number(e.target.value))
|
||||
}
|
||||
className="w-full bg-background/80 border border-border/50 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo/50"
|
||||
>
|
||||
{allPages.map((page) => (
|
||||
<option key={page.pageNumber} value={page.pageNumber}>
|
||||
Page {page.pageNumber}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prompt Input */}
|
||||
<div className="relative glass-panel p-1 rounded-xl group focus-within:border-indigo/30 transition-colors">
|
||||
<div className="bg-background/80 rounded-lg p-4 border border-border/50">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<label className="text-[10px] uppercase text-muted-foreground tracking-[0.02em] font-medium">
|
||||
Prompt
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span className="capitalize">{selectedStyleName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Continue the story... Describe what happens next in your comic."
|
||||
placeholder="Continue the story... Describe what happens next."
|
||||
disabled={isGenerating}
|
||||
className="w-full bg-transparent border-none text-sm text-white placeholder-muted-foreground/50 focus:ring-0 focus:outline-none resize-none h-20 leading-relaxed tracking-tight"
|
||||
/>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border/30 space-y-3">
|
||||
{/* Existing Characters */}
|
||||
{referencePage &&
|
||||
referencePage.characterImages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-[0.02em] font-medium">
|
||||
Reuse Characters
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{referencePage.characterImages.map((characterUrl) => {
|
||||
const isSelected =
|
||||
selectedExistingCharacters.includes(characterUrl);
|
||||
return (
|
||||
<button
|
||||
key={characterUrl}
|
||||
onClick={() =>
|
||||
toggleExistingCharacter(characterUrl)
|
||||
}
|
||||
className={`relative w-8 h-8 rounded-md overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-indigo shadow-sm shadow-indigo/20"
|
||||
: "border-border/50 hover:border-indigo/50"
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={characterUrl}
|
||||
alt="Existing character"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute inset-0 bg-indigo/20 flex items-center justify-center">
|
||||
<div className="w-3 h-3 bg-indigo rounded-full flex items-center justify-center">
|
||||
<div className="w-1 h-1 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Character Uploads */}
|
||||
{/* Character Upload */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{uploadedFiles.length > 0 ? (
|
||||
{previews.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{previews.map((preview, index) => (
|
||||
<div key={index} className="relative group/thumb">
|
||||
@@ -294,10 +172,11 @@ export function GeneratePageModal({
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
e.stopPropagation()
|
||||
removeFile(index)
|
||||
}}
|
||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity"
|
||||
disabled={isGenerating}
|
||||
className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover/thumb:opacity-100 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
<X className="w-2.5 h-2.5 text-white" />
|
||||
</button>
|
||||
@@ -305,8 +184,9 @@ export function GeneratePageModal({
|
||||
))}
|
||||
{uploadedFiles.length < 2 && (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors"
|
||||
onClick={() => !isGenerating && fileInputRef.current?.click()}
|
||||
disabled={isGenerating}
|
||||
className="w-8 h-8 rounded-md border border-dashed border-border/50 hover:border-indigo/50 flex items-center justify-center text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -314,31 +194,33 @@ export function GeneratePageModal({
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors"
|
||||
onClick={() => !isGenerating && fileInputRef.current?.click()}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Upload New Characters</span>
|
||||
<span className="text-muted-foreground/50">
|
||||
(Max 2)
|
||||
</span>
|
||||
<span>Add new characters (optional)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground/70">
|
||||
Automatically references previous pages and existing characters from your story.
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || isGenerating}
|
||||
@@ -347,7 +229,7 @@ export function GeneratePageModal({
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Generating...</span>
|
||||
<span>Generating page...</span>
|
||||
</>
|
||||
) : (
|
||||
`Generate Page ${pageNumber}`
|
||||
@@ -357,12 +239,13 @@ export function GeneratePageModal({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Character Preview Modal */}
|
||||
{showPreview !== null && previews[showPreview] && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
|
||||
onClick={() => setShowPreview(null)}
|
||||
>
|
||||
<div className="relative max-w-2xl max-h-[80vh] glass-panel p-4 rounded-xl z-[101]">
|
||||
<div className="relative max-w-sm max-h-[80vh] glass-panel p-4 rounded-xl z-[101]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { COMIC_STYLES } from "./constants";
|
||||
|
||||
export function buildComicPrompt({
|
||||
prompt,
|
||||
style,
|
||||
characterImages = [],
|
||||
isContinuation = false,
|
||||
previousContext = "",
|
||||
isAddPage = false,
|
||||
previousPages = [],
|
||||
}: {
|
||||
prompt: string;
|
||||
style?: string;
|
||||
characterImages?: string[];
|
||||
isContinuation?: boolean;
|
||||
previousContext?: string;
|
||||
isAddPage?: boolean;
|
||||
previousPages?: Array<{
|
||||
prompt: string;
|
||||
characterImages: string[];
|
||||
}>;
|
||||
}): string {
|
||||
const styleInfo = COMIC_STYLES.find((s) => s.id === style);
|
||||
const styleDesc = styleInfo?.prompt || COMIC_STYLES[2].prompt;
|
||||
|
||||
let continuationContext = "";
|
||||
if (isContinuation && previousContext) {
|
||||
continuationContext = `\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`;
|
||||
}
|
||||
|
||||
if (isAddPage && previousPages.length > 0) {
|
||||
const storyHistory = previousPages
|
||||
.map((page, index) => `Page ${index + 1}: ${page.prompt}`)
|
||||
.join('\n');
|
||||
|
||||
continuationContext = `\nSTORY CONTINUATION CONTEXT:\nThis is a continuation of an existing comic story. Here are the previous pages:\n${storyHistory}\n\nThe new page should naturally continue this story. Maintain the same characters, setting, and narrative style. Reference previous events and build upon them.\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`;
|
||||
|
||||
return `${systemPrompt}\n\nSTORY:\n${prompt}`;
|
||||
}
|
||||
Reference in New Issue
Block a user