Files
make-comics/app/api/add-page/route.ts
T
2026-01-05 23:06:30 +01:00

254 lines
7.2 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import Together from "together-ai";
import { db } from "@/lib/db";
import { pages } from "@/lib/schema";
import { eq } from "drizzle-orm";
import {
updatePage,
createPage,
getNextPageNumber,
getStoryWithPagesBySlug,
getLastPageImage,
deletePage,
} from "@/lib/db-actions";
import { freeTierRateLimit } from "@/lib/rate-limit";
import { uploadImageToS3 } from "@/lib/s3-upload";
import { buildComicPrompt } from "@/lib/prompt";
import { isContentPolicyViolation, getContentPolicyErrorMessage } from "@/lib/utils";
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,
pageId,
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 });
}
let page;
let pageNumber;
let isRedraw = false;
if (pageId) {
// Redraw mode: update existing page
isRedraw = true;
const storyData = await getStoryWithPagesBySlug(storyId);
if (!storyData) {
return NextResponse.json({ error: "Story not found" }, { status: 404 });
}
const existingPage = storyData.pages.find((p) => p.id === pageId);
if (!existingPage) {
return NextResponse.json({ error: "Page not found" }, { status: 404 });
}
page = existingPage;
pageNumber = existingPage.pageNumber;
} else {
// Add new page mode
pageNumber = await getNextPageNumber(story.id);
page = await createPage({
storyId: story.id,
pageNumber,
prompt,
characterImageUrls: characterImages,
});
}
const dimensions = FIXED_DIMENSIONS;
// Collect reference images: previous page + story characters + current characters
let referenceImages: string[] = [];
// Get previous page image for style consistency (unless it's page 1)
if (pageNumber > 1) {
// Always use the previous page's image, regardless of new page or redraw
const storyData = await getStoryWithPagesBySlug(storyId);
if (storyData) {
const previousPage = storyData.pages.find(
(p) => p.pageNumber === pageNumber - 1
);
if (previousPage?.generatedImageUrl) {
referenceImages.push(previousPage.generatedImageUrl);
}
}
}
// Use only the character images sent from the frontend (user's selection)
// These are already the most recent/relevant characters the user wants to use
referenceImages.push(...characterImages);
// Build the prompt with continuation context
// For redraw, only include pages up to the current page being redrawn
// For new page, include all existing pages
const relevantPages = isRedraw
? pages.filter((p) => p.pageNumber < pageNumber)
: pages;
const previousPages = relevantPages.map((p) => ({
prompt: p.prompt,
}));
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,
reference_images:
referenceImages.length > 0 ? referenceImages : undefined,
});
} catch (error) {
console.error("Together AI API error:", error);
// Clean up DB records if generation failed due to content policy
try {
if (error instanceof Error && error.message && error.message.includes("NO_IMAGE")) {
if (isRedraw) {
// For redraw, we don't delete the page, just don't update it
} else {
// For new page, delete the page that was created
await deletePage(page.id);
}
}
} catch (cleanupError) {
console.error("Error cleaning up DB on image generation failure:", cleanupError);
}
if (error instanceof Error && error.message && isContentPolicyViolation(error.message)) {
return NextResponse.json(
{
error: getContentPolicyErrorMessage(),
errorType: "content_policy",
},
{ status: 400 }
);
}
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);
// Apply rate limiting for free tier after successful generation
const hasApiKey = request.headers.get("x-api-key");
if (!hasApiKey) {
try {
await freeTierRateLimit.limit(userId);
} catch (rateLimitError) {
console.error("Error applying rate limit after successful generation:", rateLimitError);
// Don't fail the request if rate limiting fails, just log it
}
}
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 }
);
}
}