feat: integrate Sensei Spook mascot in 4 stances, speech bubbles, and style updates

This commit is contained in:
Ajasra committed 2026-10-28 01:20:01 +08:00
commit e04672db2a
104 files changed
+8262

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
// Provider cooldown is completely disabled: transient failures or safety rejections
// will never lock out the API for subsequent requests.
export function isProviderAvailable(_providerName: string): boolean {
return true;
}
export function triggerCooldown(
_providerName: string,
_durationMinutes: number,
_reason: string
): void {
// No-op: Cooldown disabled
}
export function resetProvider(_providerName: string): void {
// No-op
}
+156
View File
@@ -0,0 +1,156 @@
import { ContentSafetyError } from "./safety";
export interface GeminiGenerateOptions {
model: string;
prompt: string;
negativePrompt?: string;
imageBase64: string;
mimeType: string;
timeoutMs: number;
enableSfwCheck: boolean;
}
export interface GeminiResult {
imageUrl: string;
provider: string;
model: string;
}
export async function generateWithGemini(
options: GeminiGenerateOptions
): Promise<GeminiResult> {
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
throw new Error("GEMINI_API_KEY is not configured in environment variables");
}
// Clean base64 string
const cleanBase64 = options.imageBase64.replace(/^data:image\/\w+;base64,/, "");
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${options.model}:generateContent?key=${apiKey}`;
const requestBody: any = {
contents: [
{
role: "user",
parts: [
{
text: `${options.prompt}\n\nNegative directives: ${options.negativePrompt || "none"}`,
},
{
inlineData: {
mimeType: options.mimeType || "image/jpeg",
data: cleanBase64,
},
},
],
},
],
};
if (options.enableSfwCheck) {
requestBody.safetySettings = [
{
category: "HARM_CATEGORY_SEXUALLY_EXPLICIT",
threshold: "BLOCK_MEDIUM_AND_ABOVE",
},
{
category: "HARM_CATEGORY_HATE_SPEECH",
threshold: "BLOCK_MEDIUM_AND_ABOVE",
},
{
category: "HARM_CATEGORY_HARASSMENT",
threshold: "BLOCK_MEDIUM_AND_ABOVE",
},
{
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
threshold: "BLOCK_MEDIUM_AND_ABOVE",
},
];
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("PROHIBITED_CONTENT") ||
errorText.includes("HARM_CATEGORY") ||
errorText.includes("SAFETY")
) {
throw new ContentSafetyError(
`Image rejected by content safety filter: PROHIBITED_CONTENT`
);
}
throw new Error(`Gemini API error [${response.status}]: ${errorText}`);
}
const data = await response.json();
// Check safety feedback
if (data.promptFeedback?.blockReason) {
throw new ContentSafetyError(
`Image rejected by content safety filter: ${data.promptFeedback.blockReason}`
);
}
// Extract generated image from candidates
const candidate = data.candidates?.[0];
if (!candidate) {
throw new Error("Gemini returned no candidates in response");
}
// Check candidate finishReason for safety blocks
if (candidate.finishReason === "SAFETY" || candidate.finishReason === "BLOCKLIST") {
throw new ContentSafetyError(
"Image rejected by content safety filter: output triggered Safe-for-Work (SFW) protection."
);
}
// Look for inline image in response parts
const parts = candidate.content?.parts || [];
for (const part of parts) {
if (part.inlineData?.data) {
const mime = part.inlineData.mimeType || "image/png";
return {
imageUrl: `data:${mime};base64,${part.inlineData.data}`,
provider: "gemini",
model: options.model,
};
}
}
// If text returned or URL in text
const textPart = parts.find((p: any) => p.text)?.text || "";
const urlMatch = textPart.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
if (urlMatch) {
return {
imageUrl: urlMatch[0],
provider: "gemini",
model: options.model,
};
}
throw new Error(
"Gemini did not return an image part in the response payload. Please verify model image generation capabilities."
);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error(`Gemini request timed out after ${options.timeoutMs}ms`);
}
throw error;
}
}
+133
View File
@@ -0,0 +1,133 @@
export interface OpenRouterGenerateOptions {
model: string;
prompt: string;
negativePrompt?: string;
imageBase64: string;
mimeType: string;
timeoutMs: number;
}
export interface OpenRouterResult {
imageUrl: string;
provider: string;
model: string;
}
export async function generateWithOpenRouter(
options: OpenRouterGenerateOptions
): Promise<OpenRouterResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not configured in environment variables");
}
const cleanBase64 = options.imageBase64.startsWith("data:")
? options.imageBase64
: `data:${options.mimeType || "image/jpeg"};base64,${options.imageBase64}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeoutMs);
try {
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"HTTP-Referer": "https://photobooth.local",
"X-Title": "AI PhotoBooth",
},
body: JSON.stringify({
model: options.model,
messages: [
{
role: "user",
content: [
{
type: "text",
text: `${options.prompt}\n\nNegative prompt: ${options.negativePrompt || "none"}`,
},
{
type: "image_url",
image_url: {
url: cleanBase64,
},
},
],
},
],
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OpenRouter API error [${response.status}]: ${errorText}`);
}
const data = await response.json();
const choice = data.choices?.[0];
const message = choice?.message;
// 1. Check OpenRouter image output attachments (message.images: [{ image_url: { url: ... } }])
if (Array.isArray(message?.images) && message.images.length > 0) {
for (const img of message.images) {
const url = img?.image_url?.url || img?.url;
if (url) {
return {
imageUrl: url,
provider: "openrouter",
model: options.model,
};
}
}
}
const content = message?.content;
if (!content) {
throw new Error("OpenRouter returned an empty completion");
}
// Check if content is or contains an image URL or base64 data
const base64Match = content.match(/data:image\/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+/);
if (base64Match) {
return {
imageUrl: base64Match[0],
provider: "openrouter",
model: options.model,
};
}
const urlMatch = content.match(/https?:\/\/[^\s"']+\.(?:png|jpg|jpeg|webp)/i);
if (urlMatch) {
return {
imageUrl: urlMatch[0],
provider: "openrouter",
model: options.model,
};
}
// Some models return markdown image: ![alt](url)
const mdMatch = content.match(/!\[.*?\]\((https?:\/\/.*?)\)/);
if (mdMatch) {
return {
imageUrl: mdMatch[1],
provider: "openrouter",
model: options.model,
};
}
throw new Error(
`OpenRouter response did not contain an image. Output received: ${content.slice(0, 120)}...`
);
} catch (error: any) {
clearTimeout(timeoutId);
if (error.name === "AbortError") {
throw new Error(`OpenRouter request timed out after ${options.timeoutMs}ms`);
}
throw error;
}
}
+125
View File
@@ -0,0 +1,125 @@
import { BoothConfig, BoothStyle } from "@/types/booth";
import { generateWithGemini } from "./gemini";
import { generateWithOpenRouter } from "./openrouter";
import {
isProviderAvailable,
triggerCooldown,
resetProvider,
} from "./circuit-breaker";
import { ContentSafetyError, isContentSafetyError } from "./safety";
export interface OrchestrationRequest {
config: BoothConfig;
style: BoothStyle;
imageBase64: string;
mimeType?: string;
}
export interface OrchestrationResult {
imageUrl: string;
providerUsed: "gemini" | "openrouter" | "demo_mock";
modelUsed: string;
durationMs: number;
fallbackTriggered: boolean;
warnings?: string[];
}
export async function orchestrateGeneration(
request: OrchestrationRequest
): Promise<OrchestrationResult> {
const startTime = Date.now();
const { config, style, imageBase64 } = request;
const mimeType = request.mimeType || "image/jpeg";
const warnings: string[] = [];
const hasGeminiKey = Boolean(process.env.GEMINI_API_KEY);
const hasOpenRouterKey = Boolean(process.env.OPENROUTER_API_KEY);
// Demo / Offline Mode fallback when no API keys are provided yet
if (!hasGeminiKey && !hasOpenRouterKey) {
console.log("[AI Orchestrator] No API keys configured. Using Demo Mode sample image.");
// Simulate generation delay (2 seconds)
await new Promise((r) => setTimeout(r, 2000));
return {
imageUrl: style.sampleResult || style.thumbnail || "/assets/styles/sample_clay_pink.png",
providerUsed: "demo_mock",
modelUsed: "demo-simulator",
durationMs: Date.now() - startTime,
fallbackTriggered: false,
warnings: ["Demo Mode active: Set GEMINI_API_KEY or OPENROUTER_API_KEY in .env.local to enable live AI generation."],
};
}
// 1. Try Primary Provider (Google Gemini)
if (isProviderAvailable("gemini") && hasGeminiKey) {
try {
console.log(`[AI Orchestrator] Attempting Primary (Gemini: ${config.ai.primary.model})...`);
const result = await generateWithGemini({
model: config.ai.primary.model,
prompt: style.prompt,
negativePrompt: style.negativePrompt,
imageBase64,
mimeType,
timeoutMs: config.ai.primary.timeoutMs,
enableSfwCheck: config.ai.enableSfwCheck,
});
resetProvider("gemini");
return {
imageUrl: result.imageUrl,
providerUsed: "gemini",
modelUsed: result.model,
durationMs: Date.now() - startTime,
fallbackTriggered: false,
};
} catch (primaryError: any) {
// Content safety rejections are caused by user input, NOT provider downtime
if (isContentSafetyError(primaryError)) {
console.warn(`[AI Orchestrator] Primary provider (Gemini) rejected image for safety:`, primaryError.message);
throw new ContentSafetyError(primaryError.message);
}
console.warn(`[AI Orchestrator] Primary provider (Gemini) failed:`, primaryError.message);
warnings.push(`Primary failed: ${primaryError.message}`);
}
} else {
warnings.push("Primary provider key missing. Skipping to backup.");
}
// 2. Try Backup Provider (OpenRouter)
if (isProviderAvailable("openrouter") && hasOpenRouterKey) {
try {
console.log(`[AI Orchestrator] Attempting Backup (OpenRouter: ${config.ai.backup.model})...`);
const result = await generateWithOpenRouter({
model: config.ai.backup.model,
prompt: style.prompt,
negativePrompt: style.negativePrompt,
imageBase64,
mimeType,
timeoutMs: config.ai.backup.timeoutMs,
});
return {
imageUrl: result.imageUrl,
providerUsed: "openrouter",
modelUsed: result.model,
durationMs: Date.now() - startTime,
fallbackTriggered: true,
warnings,
};
} catch (backupError: any) {
if (isContentSafetyError(backupError)) {
console.warn(`[AI Orchestrator] Backup provider (OpenRouter) rejected image for safety:`, backupError.message);
throw new ContentSafetyError(backupError.message);
}
console.error(`[AI Orchestrator] Backup provider (OpenRouter) failed:`, backupError.message);
warnings.push(`Backup failed: ${backupError.message}`);
}
}
// If both providers failed or were unavailable, throw descriptive error
throw new Error(
`AI Generation failed on all available providers. Details: ${warnings.join(" | ")}`
);
}
+28
View File
@@ -0,0 +1,28 @@
export class ContentSafetyError extends Error {
public readonly isSafetyRejection = true;
constructor(message: string) {
super(message);
this.name = "ContentSafetyError";
Object.setPrototypeOf(this, ContentSafetyError.prototype);
}
}
export function isContentSafetyError(err: any): boolean {
if (!err) return false;
if (err.isSafetyRejection || err.name === "ContentSafetyError") return true;
const msg = String(err.message || err.error || err || "").toLowerCase();
return (
msg.includes("safety filter") ||
msg.includes("prohibited_content") ||
msg.includes("harm_category") ||
msg.includes("safe-for-work") ||
msg.includes("finishreason: safety") ||
msg.includes("finishreason: blocklist") ||
msg.includes("blockreason") ||
msg.includes("content safety") ||
msg.includes("safety policy") ||
msg.includes("violates safety")
);
}