403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
|
import Together from "together-ai";
|
|
import { auth } from "@clerk/nextjs/server";
|
|
import {
|
|
updatePage,
|
|
updateStory,
|
|
createStory,
|
|
createPage,
|
|
getNextPageNumber,
|
|
getStoryById,
|
|
getLastPageImage,
|
|
deletePage,
|
|
deleteStory,
|
|
} from "@/lib/db-actions";
|
|
import { freeTierRateLimit } from "@/lib/rate-limit";
|
|
import { COMIC_STYLES } from "@/lib/constants";
|
|
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 };
|
|
|
|
const TEXT_MODEL = "Qwen/Qwen3.5-9B";
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const { userId } = await auth();
|
|
|
|
if (!userId) {
|
|
return NextResponse.json(
|
|
{ error: "Authentication required" },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
const {
|
|
storyId,
|
|
prompt,
|
|
apiKey,
|
|
style = "noir",
|
|
characterImages = [],
|
|
isContinuation = false,
|
|
previousContext = "",
|
|
} = await request.json();
|
|
|
|
if (!prompt) {
|
|
return NextResponse.json(
|
|
{ error: "Missing required fields" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
// Determine which API key to use
|
|
let finalApiKey = apiKey;
|
|
const isUsingFreeTier = !apiKey;
|
|
const usesOwnApiKey = !!apiKey;
|
|
|
|
if (isUsingFreeTier) {
|
|
// Use default API key for free tier
|
|
finalApiKey = process.env.TOGETHER_API_KEY;
|
|
if (!finalApiKey) {
|
|
return NextResponse.json(
|
|
{
|
|
error: "Server configuration error - default API key not available",
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
let page;
|
|
let story;
|
|
let referenceImages: string[] = [];
|
|
|
|
if (storyId) {
|
|
// Continuation: get previous page image and story character images
|
|
story = await getStoryById(storyId);
|
|
if (!story) {
|
|
return NextResponse.json({ error: "Story not found" }, { status: 404 });
|
|
}
|
|
|
|
const nextPageNumber = await getNextPageNumber(storyId);
|
|
page = await createPage({
|
|
storyId,
|
|
pageNumber: nextPageNumber,
|
|
prompt,
|
|
characterImageUrls: characterImages,
|
|
});
|
|
|
|
// Get previous page image for style consistency (unless it's page 1)
|
|
if (nextPageNumber > 1) {
|
|
const lastPageImage = await getLastPageImage(storyId);
|
|
if (lastPageImage) {
|
|
referenceImages.push(lastPageImage);
|
|
}
|
|
}
|
|
|
|
// For continuation pages, character images are sent from frontend
|
|
// No need to fetch separately - frontend handles selection
|
|
} else {
|
|
// New story: no previous page reference
|
|
// Create story with temporary title, will update with generated title
|
|
story = await createStory({
|
|
title: prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
|
description: undefined,
|
|
userId: userId,
|
|
style,
|
|
usesOwnApiKey,
|
|
});
|
|
|
|
page = await createPage({
|
|
storyId: story.id,
|
|
pageNumber: 1,
|
|
prompt,
|
|
characterImageUrls: characterImages,
|
|
});
|
|
}
|
|
|
|
// Use only the character images sent from the frontend
|
|
referenceImages.push(...characterImages);
|
|
|
|
const dimensions = FIXED_DIMENSIONS;
|
|
|
|
const fullPrompt = buildComicPrompt({
|
|
prompt,
|
|
style,
|
|
characterImages,
|
|
isContinuation,
|
|
previousContext,
|
|
});
|
|
|
|
const client = new Together({ apiKey: finalApiKey });
|
|
|
|
// Generate title and description in parallel with image generation (only for new stories)
|
|
let titleGenerationPromise: Promise<{
|
|
title: string;
|
|
description: string;
|
|
}> | null = null;
|
|
if (!storyId) {
|
|
titleGenerationPromise = (async () => {
|
|
try {
|
|
const titlePrompt = `Based on this comic book prompt, generate a compelling title and description for the comic book.
|
|
|
|
Prompt: "${prompt}"
|
|
Style: ${COMIC_STYLES.find((s) => s.id === style)?.name || style}
|
|
|
|
Generate:
|
|
1. A catchy, engaging title (maximum 60 characters)
|
|
2. A brief description (2-3 sentences, maximum 200 characters)
|
|
|
|
Format your response as JSON:
|
|
{
|
|
"title": "Title here",
|
|
"description": "Description here"
|
|
}
|
|
|
|
Only return the JSON, no other text.`;
|
|
|
|
const textResponse = await client.chat.completions.create({
|
|
model: TEXT_MODEL,
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content:
|
|
"You are a creative assistant that generates compelling comic book titles and descriptions. Always respond with valid JSON only.",
|
|
},
|
|
{
|
|
role: "user",
|
|
content: titlePrompt,
|
|
},
|
|
],
|
|
temperature: 0.8,
|
|
max_tokens: 300,
|
|
});
|
|
|
|
const content = textResponse.choices[0]?.message?.content?.trim();
|
|
if (!content) {
|
|
throw new Error("No response from text generation");
|
|
}
|
|
|
|
// Extract JSON from response (in case there's extra text)
|
|
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
if (!jsonMatch) {
|
|
throw new Error("No JSON found in response");
|
|
}
|
|
|
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
const rawTitle =
|
|
parsed.title?.trim() ||
|
|
(prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt);
|
|
const rawDescription = parsed.description?.trim();
|
|
|
|
// Enforce character limits
|
|
const title =
|
|
rawTitle.length > 60 ? rawTitle.substring(0, 57) + "..." : rawTitle;
|
|
const description =
|
|
rawDescription && rawDescription.length > 200
|
|
? rawDescription.substring(0, 197) + "..."
|
|
: rawDescription;
|
|
|
|
return {
|
|
title,
|
|
description: description || undefined,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error generating title and description:", error);
|
|
// Fallback to prompt-based title
|
|
return {
|
|
title:
|
|
prompt.length > 50 ? prompt.substring(0, 50) + "..." : prompt,
|
|
description: undefined,
|
|
};
|
|
}
|
|
})();
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
console.log("Starting image generation for ...");
|
|
console.dir({
|
|
fullPrompt,
|
|
referenceImages,
|
|
});
|
|
const startTime = Date.now();
|
|
response = await client.images.generate({
|
|
model: IMAGE_MODEL,
|
|
prompt: fullPrompt,
|
|
width: dimensions.width,
|
|
height: dimensions.height,
|
|
temperature: 0.1, // Lower temperature for more consistent face matching
|
|
reference_images:
|
|
referenceImages.length > 0 ? referenceImages : undefined,
|
|
});
|
|
const endTime = Date.now();
|
|
const durationMs = endTime - startTime;
|
|
const durationSeconds = (durationMs / 1000).toFixed(2);
|
|
console.log(`Image generation completed in ${durationSeconds} seconds`);
|
|
} catch (error) {
|
|
console.error("Together AI API error:", error);
|
|
|
|
// Clean up DB records if generation failed
|
|
try {
|
|
if (!storyId) {
|
|
// New story failed
|
|
await deleteStory(story!.id);
|
|
} else {
|
|
// Continuation failed
|
|
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. Please add credits to your Together.ai account at https://api.together.ai/settings/billing or update your API key.",
|
|
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;
|
|
|
|
// Upload image to S3 for permanent storage
|
|
const s3Key = `${storyId || story!.id}/page-${
|
|
page.pageNumber
|
|
}-${Date.now()}.jpg`;
|
|
const s3ImageUrl = await uploadImageToS3(imageUrl, s3Key);
|
|
|
|
// Wait for title/description generation if it's a new story
|
|
let generatedTitle: string | undefined;
|
|
let generatedDescription: string | undefined;
|
|
if (titleGenerationPromise) {
|
|
const titleData = await titleGenerationPromise;
|
|
generatedTitle = titleData.title;
|
|
generatedDescription = titleData.description;
|
|
|
|
// Update story with generated title and description
|
|
try {
|
|
await updateStory(story!.id, {
|
|
title: generatedTitle,
|
|
description: generatedDescription,
|
|
});
|
|
// Update story object for response
|
|
story = {
|
|
...story,
|
|
title: generatedTitle,
|
|
description: generatedDescription,
|
|
};
|
|
} catch (dbError) {
|
|
console.error("Error updating story title/description:", dbError);
|
|
// Continue even if update fails
|
|
}
|
|
}
|
|
|
|
// Update page in database with S3 URL
|
|
try {
|
|
await updatePage(page.id, s3ImageUrl);
|
|
} catch (dbError) {
|
|
console.error("Error updating page in database:", dbError);
|
|
return NextResponse.json(
|
|
{ error: "Failed to save generated image" },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
// Apply rate limiting for free tier after successful generation
|
|
if (isUsingFreeTier) {
|
|
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
|
|
}
|
|
}
|
|
|
|
const responseData = storyId
|
|
? { imageUrl: s3ImageUrl, pageId: page.id, pageNumber: page.pageNumber }
|
|
: {
|
|
imageUrl: s3ImageUrl,
|
|
storyId: story!.id,
|
|
storySlug: story!.slug,
|
|
pageId: page.id,
|
|
pageNumber: page.pageNumber,
|
|
title: generatedTitle || story!.title,
|
|
description: generatedDescription || story!.description,
|
|
};
|
|
|
|
return NextResponse.json(responseData);
|
|
} catch (error) {
|
|
console.error("Error in generate-comic API:", error);
|
|
return NextResponse.json(
|
|
{
|
|
error: `Internal server error: ${
|
|
error instanceof Error ? error.message : "Unknown error"
|
|
}`,
|
|
},
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|