feat: add S3 upload configuration and Together.ai API integration

This commit is contained in:
Riccardo Giorato
2025-12-23 12:20:03 +01:00
parent 62645297c8
commit 3eea61625d
5 changed files with 1606 additions and 100 deletions
+5
View File
@@ -0,0 +1,5 @@
TOGETHER_API_KEY=
S3_UPLOAD_KEY=
S3_UPLOAD_SECRET=
S3_UPLOAD_BUCKET=
S3_UPLOAD_REGION=
+122 -93
View File
@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server";
import Together from "together-ai";
const FIXED_DIMENSIONS = { width: 864, height: 1184 } const FIXED_DIMENSIONS = { width: 864, height: 1184 };
const STYLE_DESCRIPTIONS: Record<string, string> = { const STYLE_DESCRIPTIONS: Record<string, string> = {
noir: "film noir style, high contrast black and white, deep dramatic shadows, 1940s detective aesthetic, heavy bold inking, moody atmospheric lighting", noir: "film noir style, high contrast black and white, deep dramatic shadows, 1940s detective aesthetic, heavy bold inking, moody atmospheric lighting",
@@ -14,28 +15,34 @@ const STYLE_DESCRIPTIONS: Record<string, string> = {
"contemporary digital comic art, smooth gradient coloring, detailed realistic backgrounds, cinematic widescreen composition, graphic novel quality", "contemporary digital comic art, smooth gradient coloring, detailed realistic backgrounds, cinematic widescreen composition, graphic novel quality",
watercolor: watercolor:
"painted watercolor comic style, soft blended edges, flowing artistic colors, delicate linework with painted fills, ethereal atmosphere", "painted watercolor comic style, soft blended edges, flowing artistic colors, delicate linework with painted fills, ethereal atmosphere",
} };
async function analyzeCharacterImage(imageBase64: string, apiKey: string, characterNumber: number): Promise<string> { async function analyzeCharacterImage(
imageBase64: string,
apiKey: string,
characterNumber: number
): Promise<string> {
try { try {
// Clean base64 string // Clean base64 string
const base64Data = imageBase64.replace(/^data:image\/[^;]+;base64,/, "") const base64Data = imageBase64.replace(/^data:image\/[^;]+;base64,/, "");
const response = await fetch("https://api.together.xyz/v1/chat/completions", { const response = await fetch(
method: "POST", "https://api.together.xyz/v1/chat/completions",
headers: { {
Authorization: `Bearer ${apiKey}`, method: "POST",
"Content-Type": "application/json", headers: {
}, Authorization: `Bearer ${apiKey}`,
body: JSON.stringify({ "Content-Type": "application/json",
model: "meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo", },
messages: [ body: JSON.stringify({
{ model: "meta-llama/Llama-3.2-11B-Vision-Instruct-Turbo",
role: "user", messages: [
content: [ {
{ role: "user",
type: "text", content: [
text: `Analyze this person for a comic book character reference. Provide a detailed physical description in one paragraph. Include: {
type: "text",
text: `Analyze this person for a comic book character reference. Provide a detailed physical description in one paragraph. Include:
- Gender and approximate age - Gender and approximate age
- Face shape (round, oval, square, etc.) - Face shape (round, oval, square, etc.)
- Hair: color, length, style, texture - Hair: color, length, style, texture
@@ -46,33 +53,38 @@ async function analyzeCharacterImage(imageBase64: string, apiKey: string, charac
- Current outfit/clothing style and colors - Current outfit/clothing style and colors
Be VERY specific and detailed. This description will be used to draw this exact person as a comic character. Respond ONLY with the physical description, no other text.`, Be VERY specific and detailed. This description will be used to draw this exact person as a comic character. Respond ONLY with the physical description, no other text.`,
},
{
type: "image_url",
image_url: {
url: `data:image/jpeg;base64,${base64Data}`,
}, },
}, {
], type: "image_url",
}, image_url: {
], url: `data:image/jpeg;base64,${base64Data}`,
max_tokens: 500, },
temperature: 0.3, },
}), ],
}) },
],
max_tokens: 500,
temperature: 0.3,
}),
}
);
if (!response.ok) { if (!response.ok) {
console.error(`[v0] Vision API error for character ${characterNumber}:`, await response.text()) console.error(
return `Character ${characterNumber}` `[v0] Vision API error for character ${characterNumber}:`,
await response.text()
);
return `Character ${characterNumber}`;
} }
const data = await response.json() const data = await response.json();
const description = data.choices?.[0]?.message?.content || `Character ${characterNumber}` const description =
console.log(`[v0] Character ${characterNumber} description:`, description) data.choices?.[0]?.message?.content || `Character ${characterNumber}`;
return description console.log(`[v0] Character ${characterNumber} description:`, description);
return description;
} catch (error) { } catch (error) {
console.error(`[v0] Error analyzing character ${characterNumber}:`, error) console.error(`[v0] Error analyzing character ${characterNumber}:`, error);
return `Character ${characterNumber}` return `Character ${characterNumber}`;
} }
} }
@@ -85,27 +97,34 @@ export async function POST(request: NextRequest) {
characterImages = [], characterImages = [],
isContinuation = false, isContinuation = false,
previousContext = "", previousContext = "",
} = await request.json() } = await request.json();
if (!prompt || !apiKey) { if (!prompt || !apiKey) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 }) return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
} }
const dimensions = FIXED_DIMENSIONS const dimensions = FIXED_DIMENSIONS;
const styleDesc = STYLE_DESCRIPTIONS[style] || STYLE_DESCRIPTIONS.noir const styleDesc = STYLE_DESCRIPTIONS[style] || STYLE_DESCRIPTIONS.noir;
const continuationContext = const continuationContext =
isContinuation && previousContext 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` ? `\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 = "" let characterSection = "";
if (characterImages.length > 0) { if (characterImages.length > 0) {
console.log(`[v0] Analyzing ${characterImages.length} character image(s)...`) console.log(
`[v0] Analyzing ${characterImages.length} character image(s)...`
);
const characterDescriptions = await Promise.all( const characterDescriptions = await Promise.all(
characterImages.map((img: string, index: number) => analyzeCharacterImage(img, apiKey, index + 1)), characterImages.map((img: string, index: number) =>
) analyzeCharacterImage(img, apiKey, index + 1)
)
);
if (characterImages.length === 1) { if (characterImages.length === 1) {
characterSection = ` characterSection = `
@@ -116,7 +135,7 @@ CRITICAL INSTRUCTIONS:
- This EXACT character must appear in ALL 5 panels - This EXACT character must appear in ALL 5 panels
- Draw them in ${style} comic art style but keep their EXACT appearance - Draw them in ${style} comic art style but keep their EXACT appearance
- Same face, same hair, same outfit, same features in every panel - Same face, same hair, same outfit, same features in every panel
- They are the PROTAGONIST - center of every scene` - They are the PROTAGONIST - center of every scene`;
} else if (characterImages.length === 2) { } else if (characterImages.length === 2) {
characterSection = ` characterSection = `
TWO MAIN CHARACTERS (BOTH MUST APPEAR TOGETHER IN MOST PANELS): TWO MAIN CHARACTERS (BOTH MUST APPEAR TOGETHER IN MOST PANELS):
@@ -134,7 +153,7 @@ CRITICAL INSTRUCTIONS:
- If one is female and one is male, keep their genders correct - If one is female and one is male, keep their genders correct
- Draw both in ${style} comic art style but preserve their EXACT individual appearances - Draw both in ${style} comic art style but preserve their EXACT individual appearances
- Each character must be immediately recognizable in every panel they appear - Each character must be immediately recognizable in every panel they appear
- They are the two protagonists interacting with each other throughout the story` - They are the two protagonists interacting with each other throughout the story`;
} }
} }
@@ -163,65 +182,75 @@ COMPOSITION:
- Vary camera angles across panels: close-up, medium shot, wide establishing shot - Vary camera angles across panels: close-up, medium shot, wide establishing shot
- Natural visual flow: left-to-right, top-to-bottom reading order - Natural visual flow: left-to-right, top-to-bottom reading order
- Dynamic character poses with clear expressive acting - Dynamic character poses with clear expressive acting
- Detailed backgrounds matching the scene and mood` - Detailed backgrounds matching the scene and mood`;
const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}` const fullPrompt = `${systemPrompt}\n\nSTORY:\n${prompt}`;
const requestBody = { console.log("[v0] Generating comic with prompt length:", fullPrompt.length);
model: "google/flash-image-2.5",
prompt: fullPrompt,
width: dimensions.width,
height: dimensions.height,
n: 1,
}
console.log("[v0] Generating comic with prompt length:", fullPrompt.length) const client = new Together({ apiKey });
const response = await fetch("https://api.together.xyz/v1/images/generations", { let response;
method: "POST", try {
headers: { response = await client.images.generate({
Authorization: `Bearer ${apiKey}`, model: "google/flash-image-2.5",
"Content-Type": "application/json", prompt: fullPrompt,
}, width: dimensions.width,
body: JSON.stringify(requestBody), height: dimensions.height,
}) n: 1,
reference_images: characterImages,
});
} catch (error) {
console.error("[v0] Together AI API error:", error);
if (!response.ok) { if (error instanceof Error && "status" in error) {
const errorData = await response.json() const status = (error as any).status;
console.error("[v0] Together AI API error:", errorData) if (status === 402) {
return NextResponse.json(
if (response.status === 402) { {
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( return NextResponse.json(
{ {
error: error: error.message || `Failed to generate image: ${status}`,
"Insufficient API credits. Please add credits to your Together.ai account at https://api.together.ai/settings/billing or update your API key.", errorType: "api_error",
errorType: "credit_limit",
}, },
{ status: 402 }, { status: status || 500 }
) );
} }
return NextResponse.json( return NextResponse.json(
{ {
error: errorData.error?.message || `Failed to generate image: ${response.statusText}`, error: `Internal server error: ${
errorType: errorData.error?.type || "api_error", error instanceof Error ? error.message : "Unknown error"
}`,
}, },
{ status: response.status }, { status: 500 }
) );
} }
const data = await response.json() if (!response.data || !response.data[0] || !response.data[0].url) {
return NextResponse.json(
if (!data.data || !data.data[0] || !data.data[0].url) { { error: "No image URL in response" },
return NextResponse.json({ error: "No image URL in response" }, { status: 500 }) { status: 500 }
);
} }
return NextResponse.json({ imageUrl: data.data[0].url }) return NextResponse.json({ imageUrl: response.data[0].url });
} catch (error) { } catch (error) {
console.error("[v0] Error in generate-comic API:", error) console.error("[v0] Error in generate-comic API:", error);
return NextResponse.json( return NextResponse.json(
{ error: `Internal server error: ${error instanceof Error ? error.message : "Unknown error"}` }, {
{ status: 500 }, error: `Internal server error: ${
) error instanceof Error ? error.message : "Unknown error"
}`,
},
{ status: 500 }
);
} }
} }
+1
View File
@@ -0,0 +1 @@
export { POST } from "next-s3-upload/route";
+4 -2
View File
@@ -47,6 +47,7 @@
"input-otp": "1.4.1", "input-otp": "1.4.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "16.0.10", "next": "16.0.10",
"next-s3-upload": "^0.3.4",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-day-picker": "9.8.0", "react-day-picker": "9.8.0",
@@ -57,8 +58,9 @@
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"together-ai": "^0.33.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"zod": "3.25.76" "zod": "4.2.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
@@ -70,4 +72,4 @@
"tw-animate-css": "1.3.3", "tw-animate-css": "1.3.3",
"typescript": "^5" "typescript": "^5"
} }
} }
+1474 -5
View File
File diff suppressed because it is too large Load Diff