228 lines
6.5 KiB
TypeScript
228 lines
6.5 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,
|
|
} 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, 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 });
|
|
}
|
|
|
|
// 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 }
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (isRedraw) {
|
|
// For redraw, get all pages and find the previous page's image
|
|
const storyData = await getStoryWithPagesBySlug(storyId);
|
|
if (storyData) {
|
|
const previousPage = storyData.pages.find(p => p.pageNumber === pageNumber - 1);
|
|
if (previousPage?.generatedImageUrl) {
|
|
referenceImages.push(previousPage.generatedImageUrl);
|
|
}
|
|
}
|
|
} else {
|
|
// For new page, use the last page image
|
|
const lastPageImage = await getLastPageImage(story.id);
|
|
if (lastPageImage) {
|
|
referenceImages.push(lastPageImage);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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: referenceImages.length > 0 ? referenceImages : 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 }
|
|
);
|
|
}
|
|
} |