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:
Riccardo Giorato
2025-12-26 17:51:08 +01:00
parent 47263231af
commit 7fd5367577
5 changed files with 333 additions and 243 deletions
+175
View File
@@ -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 }
);
}
}
+8 -67
View File
@@ -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 });