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

+24
View File
@@ -0,0 +1,24 @@
import { NextRequest } from "next/server";
import crypto from "crypto";
import { constantTimeCompare } from "@/lib/security";
export function getExpectedToken(): string {
const secret = process.env.ADMIN_PASSWORD || "admin123";
if (!process.env.ADMIN_PASSWORD && process.env.NODE_ENV === "production") {
console.warn("[SECURITY WARNING] ADMIN_PASSWORD is not set in environment variables! Using insecure default.");
}
return crypto.createHash("sha256").update(`booth-portal-session-${secret}`).digest("hex");
}
export function isAuthenticated(req: NextRequest): boolean {
const cookieToken = req.cookies.get("booth_portal_token")?.value;
const authHeader = req.headers.get("authorization");
const bearerToken = authHeader?.startsWith("Bearer ") ? authHeader.substring(7) : null;
const expected = getExpectedToken();
const tokenToTest = cookieToken || bearerToken || "";
if (!tokenToTest) return false;
return constantTimeCompare(tokenToTest, expected);
}
+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")
);
}
+253
View File
@@ -0,0 +1,253 @@
import fs from "fs";
import path from "path";
import { BoothConfig, BoothStyle } from "@/types/booth";
export const GENERATION_MODEL_MAP: Record<string, { primary: string; backup: string }> = {
"nanobanana 2": {
primary: "gemini-3.1-flash-image",
backup: "google/gemini-3.1-flash-image",
},
"nanobanana": {
primary: "gemini-2.5-flash-image",
backup: "google/gemini-2.5-flash-image",
},
"lite": {
primary: "gemini-3.1-flash-lite-image",
backup: "google/gemini-3.1-flash-lite-image",
},
"nanobanana 2 lite": {
primary: "gemini-3.1-flash-lite-image",
backup: "google/gemini-3.1-flash-lite-image",
},
"pro": {
primary: "gemini-3-pro-image",
backup: "google/gemini-3-pro-image",
},
"nanobanana pro": {
primary: "gemini-3-pro-image",
backup: "google/gemini-3-pro-image",
},
};
export function resolveGenerationModels(): { primary: string; backup: string } {
const envModel = (process.env.GENERATION_MODEL || "").trim().toLowerCase();
if (envModel && GENERATION_MODEL_MAP[envModel]) {
return GENERATION_MODEL_MAP[envModel];
}
return GENERATION_MODEL_MAP["nanobanana 2"];
}
const defaultConfig: BoothConfig = {
appName: "Spooky Hand · 万圣空手",
kioskMode: true,
theme: {
backgroundType: "color",
backgroundColor: "#0b0714",
primaryColor: "#ff6d00",
accentColor: "#06b6d4",
surfaceColor: "#170f29",
textColor: "#ffffff",
logoUrl: "/assets/brand/shanghai_karate_logo.png",
logoWidth: 260,
},
camera: {
defaultFacingMode: "user",
aspectRatio: "9:16",
countdownSeconds: 3,
allowUpload: true,
maxUploadSizeMb: 10,
},
features: {
showQrCode: true,
showDownloadButton: true,
showPrintButton: false,
showRetakeButton: true,
showInstagramShare: false,
},
limits: {
maxGenerationsPerDevice: 0,
cooldownMinutes: 60,
},
ai: {
primary: {
provider: "gemini",
model: "gemini-3.1-flash-image",
timeoutMs: 28000,
},
backup: {
provider: "openrouter",
model: "google/gemini-3.1-flash-image",
timeoutMs: 30000,
},
cooldownDurationMinutes: 10,
enableSfwCheck: true,
},
kiosk: {
enableAutoReset: true,
idleTimeoutSeconds: 90,
},
labels: {
welcomeBadge: {
en: "SHANGHAI KARATE CLUB • EST. 2008",
zh: "上海健志空手道馆 · 始于2008",
},
welcomeTitle: {
en: "SPOOKY HAND 🥋🎃",
zh: "万圣“空手”道馆 🥋🎃",
},
welcomeSubtitle: {
en: "Channel Your Spooky Ki & Martial Pose!",
zh: "凝聚万圣气合,定格武道英姿!",
},
welcomeDesc: {
en: "Strike your fiercest martial arts pose! Turn your Halloween costume or karate gi into an epic Shanghai legend.",
zh: "摆出你最帅气的武道姿势!无论身穿万圣盛装还是空手道服,一键变身魔都万圣武道传奇!",
},
welcomeCta: {
en: "Enter The Haunted Dojo! ⚡",
zh: "开启万圣武道之旅!⚡",
},
cameraInstruction: {
en: "Strike a punch, block, or karate victory pose!",
zh: "摆出冲拳、格挡或胜利姿态,展现你的气魄!",
},
cameraSnap: {
en: "Snap Martial Shot",
zh: "拍摄武道照",
},
cameraUpload: {
en: "Upload Photo",
zh: "上传照片",
},
confirmTitle: {
en: "Review Your Martial Shot",
zh: "确认你的英姿",
},
retakeButton: {
en: "Retake",
zh: "重拍",
},
confirmButton: {
en: "Looks Epic! Choose Style",
zh: "超帅气!选择风格",
},
styleSelectionTitle: {
en: "Select Your Halloween Kata",
zh: "选择你的万圣奥义风格",
},
styleSelectionSubtitle: {
en: "Choose your legendary martial transformation",
zh: "挑选专属的万圣节武道变身效果",
},
confirmStyleButton: {
en: "Cast Martial Magic ⚡",
zh: "施展武道魔法 ⚡",
},
backButton: {
en: "Back",
zh: "返回",
},
processingTitle: {
en: "Gathering Martial Ki & Spooky Spirits...",
zh: "正在凝聚万圣之气与道馆灵力...",
},
processingSubtitle: {
en: "Summoning Huangpu mist, pumpkin embers & black belt magic...",
zh: "融合黄浦江晨雾、南瓜火光与黑带武道魔法...",
},
resultTitle: {
en: "Your Spooky Martial Portrait! 🥋🎃",
zh: "你的万圣武道神采!🥋🎃",
},
scanQrLabel: {
en: "Scan to save to your phone",
zh: "扫码保存至手机相册",
},
downloadLabel: {
en: "Save Portrait",
zh: "保存高清照",
},
wechatSaveTip: {
en: "In WeChat: Long press photo above to save directly!",
zh: "微信提示:长按上方图片即可直接保存到相册!",
},
printLabel: {
en: "Print Photo",
zh: "打印照片",
},
startOverLabel: {
en: "Train Again (Start Over)",
zh: "再拍一张",
},
instagramShareLabel: {
en: "Share Photo",
zh: "分享照片",
},
},
};
export function getBoothConfig(): BoothConfig {
try {
const configPath = path.join(process.cwd(), "config", "booth.config.json");
const resolvedModels = resolveGenerationModels();
if (fs.existsSync(configPath)) {
const fileContent = fs.readFileSync(configPath, "utf-8");
const parsed = JSON.parse(fileContent);
const primaryModel =
process.env.GENERATION_MODEL ||
parsed.ai?.primary?.model === "gemini-2.5-flash" ||
!parsed.ai?.primary?.model
? resolvedModels.primary
: parsed.ai.primary.model;
const backupModel =
process.env.GENERATION_MODEL ||
parsed.ai?.backup?.model === "google/gemini-2.5-flash" ||
!parsed.ai?.backup?.model
? resolvedModels.backup
: parsed.ai.backup.model;
return {
...defaultConfig,
...parsed,
ai: {
...defaultConfig.ai,
...(parsed.ai || {}),
primary: {
...defaultConfig.ai.primary,
...(parsed.ai?.primary || {}),
model: primaryModel,
},
backup: {
...defaultConfig.ai.backup,
...(parsed.ai?.backup || {}),
model: backupModel,
},
},
labels: {
...defaultConfig.labels,
...(parsed.labels || {}),
},
};
}
} catch (error) {
console.warn("Could not load booth.config.json, using fallback configuration:", error);
}
return defaultConfig;
}
export function getBoothStyles(): BoothStyle[] {
try {
const stylesPath = path.join(process.cwd(), "config", "styles.json");
if (fs.existsSync(stylesPath)) {
const fileContent = fs.readFileSync(stylesPath, "utf-8");
return JSON.parse(fileContent);
}
} catch (error) {
console.warn("Could not load styles.json, returning empty list:", error);
}
return [];
}
+85
View File
@@ -0,0 +1,85 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import { LocalizedString } from "@/types/booth";
export type Language = "en" | "zh";
interface I18nContextType {
language: Language;
setLanguage: (lang: Language) => void;
toggleLanguage: () => void;
t: (field: LocalizedString | string | undefined, fallback?: string) => string;
isWeChat: boolean;
}
const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>("zh");
const [isWeChat, setIsWeChat] = useState<boolean>(false);
useEffect(() => {
if (typeof window !== "undefined") {
// 1. Detect WeChat browser environment
const ua = navigator.userAgent.toLowerCase();
const inWeChat = ua.includes("micromessenger");
setIsWeChat(inWeChat);
// 2. Check stored preference first
const stored = localStorage.getItem("booth_lang") as Language | null;
if (stored === "en" || stored === "zh") {
setLanguageState(stored);
return;
}
// 3. Fallback to system / browser language
const browserLang = (navigator.language || "").toLowerCase();
if (browserLang.startsWith("zh")) {
setLanguageState("zh");
} else {
setLanguageState("en");
}
}
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
if (typeof window !== "undefined") {
localStorage.setItem("booth_lang", lang);
}
};
const toggleLanguage = () => {
const nextLang = language === "zh" ? "en" : "zh";
setLanguage(nextLang);
};
const t = (field: LocalizedString | string | undefined, fallback: string = ""): string => {
if (!field) return fallback;
if (typeof field === "string") return field;
return field[language] || field.en || fallback;
};
return (
<I18nContext.Provider value={{ language, setLanguage, toggleLanguage, t, isWeChat }}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) {
return {
language: "zh" as Language,
setLanguage: () => {},
toggleLanguage: () => {},
t: (field: LocalizedString | string | undefined, fallback: string = "") => {
if (!field) return fallback;
if (typeof field === "string") return field;
return field.zh || field.en || fallback;
},
isWeChat: false,
};
}
return context;
}
+58
View File
@@ -0,0 +1,58 @@
interface DeviceUsage {
count: number;
firstUsedAt: number;
}
// In-memory usage map (or keyed by hashed client token)
const usageStore = new Map<string, DeviceUsage>();
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetMinutes: number;
}
export function checkRateLimit(
clientId: string,
maxAllowed: number,
cooldownMinutes: number
): RateLimitResult {
if (!maxAllowed || maxAllowed <= 0) {
return { allowed: true, remaining: 999999, resetMinutes: 0 };
}
const now = Date.now();
const cooldownMs = cooldownMinutes * 60 * 1000;
const usage = usageStore.get(clientId);
if (!usage) {
return { allowed: true, remaining: maxAllowed, resetMinutes: 0 };
}
// Check if cooldown period has elapsed since first use
if (now - usage.firstUsedAt > cooldownMs) {
usageStore.delete(clientId);
return { allowed: true, remaining: maxAllowed, resetMinutes: 0 };
}
const remaining = Math.max(0, maxAllowed - usage.count);
const remainingCooldownMs = cooldownMs - (now - usage.firstUsedAt);
const resetMinutes = Math.ceil(remainingCooldownMs / (60 * 1000));
return {
allowed: usage.count < maxAllowed,
remaining,
resetMinutes,
};
}
export function incrementRateLimit(clientId: string): void {
const now = Date.now();
const usage = usageStore.get(clientId);
if (!usage) {
usageStore.set(clientId, { count: 1, firstUsedAt: now });
} else {
usage.count += 1;
}
}
+268
View File
@@ -0,0 +1,268 @@
import crypto from "crypto";
import { NextRequest } from "next/server";
export interface ImageValidationResult {
valid: boolean;
error?: string;
mimeType?: "image/jpeg" | "image/png" | "image/webp";
extension?: "jpg" | "png" | "webp";
sanitizedBase64?: string;
sizeBytes?: number;
}
/**
* Known file signatures (magic bytes) for allowed image formats
*/
const MAGIC_SIGNATURES = {
// JPEG: starts with FF D8 FF
jpeg: [0xff, 0xd8, 0xff],
// PNG: starts with 89 50 4E 47 0D 0A 1A 0A
png: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
// WebP: starts with "RIFF" (52 49 46 46) and at offset 8 has "WEBP" (57 45 42 50)
riff: [0x52, 0x49, 0x46, 0x46],
webp: [0x57, 0x45, 0x42, 0x50],
};
/**
* Dangerous executable & script signatures to explicitly detect and reject
*/
const DANGEROUS_SIGNATURES = [
// Windows DOS/PE executable ('MZ' - 4D 5A)
{ name: "Windows Executable (PE/DOS)", bytes: [0x4d, 0x5a] },
// Linux ELF binary (7F 45 4C 46)
{ name: "Linux ELF Binary", bytes: [0x7f, 0x45, 0x4c, 0x46] },
// Mach-O binary (FE ED FA CE / FE ED FA CF / CE FA ED FE / CF FA ED FE)
{ name: "Mach-O Binary (32-bit)", bytes: [0xfe, 0xed, 0xfa, 0xce] },
{ name: "Mach-O Binary (64-bit)", bytes: [0xfe, 0xed, 0xfa, 0xcf] },
{ name: "Mach-O Binary (rev 32)", bytes: [0xce, 0xfa, 0xed, 0xfe] },
{ name: "Mach-O Binary (rev 64)", bytes: [0xcf, 0xfa, 0xed, 0xfe] },
// Java class file (CA FE BA BE)
{ name: "Java Class File", bytes: [0xca, 0xfe, 0xba, 0xbe] },
// Shell script shebang ('#!' - 23 21)
{ name: "Shell Script", bytes: [0x23, 0x21] },
];
/**
* Matches bytes at specific offset
*/
function matchesBytes(buffer: Buffer, expected: number[], offset = 0): boolean {
if (buffer.length < offset + expected.length) return false;
for (let i = 0; i < expected.length; i++) {
if (buffer[offset + i] !== expected[i]) return false;
}
return true;
}
/**
* Validates a base64 string or data URL:
* 1. Checks max size limits
* 2. Decodes binary buffer
* 3. Scans for known executable/script signatures
* 4. Verifies genuine image magic bytes (JPEG, PNG, WebP)
* 5. Rejects scripts / HTML / SVG content
*/
export function validateAndSanitizeImage(
input: string,
maxSizeBytes: number = 10 * 1024 * 1024
): ImageValidationResult {
if (!input || typeof input !== "string") {
return { valid: false, error: "Missing or invalid image payload" };
}
// Check approximate string size (base64 string is ~4/3 larger than binary)
// 10MB binary ≈ 13.5MB base64 string
const maxStringLen = Math.ceil(maxSizeBytes * 1.4) + 1024;
if (input.length > maxStringLen) {
return { valid: false, error: `Image exceeds maximum allowed size (${Math.round(maxSizeBytes / (1024 * 1024))}MB)` };
}
// Strip possible Data URL prefix
const dataUrlMatch = input.match(/^data:([a-zA-Z0-9/+-]+);base64,(.+)$/);
const rawBase64 = dataUrlMatch ? dataUrlMatch[2] : input.trim();
// Basic Base64 character validation (safe regex pattern)
if (!/^[A-Za-z0-9+/=]+$/.test(rawBase64)) {
return { valid: false, error: "Invalid base64 encoding" };
}
let buffer: Buffer;
try {
buffer = Buffer.from(rawBase64, "base64");
} catch {
return { valid: false, error: "Failed to decode base64 payload" };
}
if (buffer.length === 0) {
return { valid: false, error: "Empty image payload" };
}
if (buffer.length > maxSizeBytes) {
return { valid: false, error: `Image binary size exceeds ${Math.round(maxSizeBytes / (1024 * 1024))}MB limit` };
}
// 1. Explicitly check for known executable or script signatures
for (const sig of DANGEROUS_SIGNATURES) {
if (matchesBytes(buffer, sig.bytes)) {
return {
valid: false,
error: `Executable or script payload detected (${sig.name}). Upload rejected.`,
};
}
}
// 2. Check for script/text content (e.g. <?php, <script, <svg, <html>)
const headerPreview = buffer.slice(0, Math.min(buffer.length, 512)).toString("utf8").toLowerCase();
const dangerousPatterns = [
"<?php",
"<% ",
"<script",
"<html",
"<body",
"<svg",
"#!/",
"eval(",
"system(",
"exec(",
];
for (const pattern of dangerousPatterns) {
if (headerPreview.includes(pattern)) {
return {
valid: false,
error: "Script or HTML content detected in image payload. Upload rejected.",
};
}
}
// 3. Verify legitimate image magic bytes
let detectedType: "image/jpeg" | "image/png" | "image/webp" | null = null;
let extension: "jpg" | "png" | "webp" = "jpg";
if (matchesBytes(buffer, MAGIC_SIGNATURES.jpeg)) {
detectedType = "image/jpeg";
extension = "jpg";
} else if (matchesBytes(buffer, MAGIC_SIGNATURES.png)) {
detectedType = "image/png";
extension = "png";
} else if (
matchesBytes(buffer, MAGIC_SIGNATURES.riff, 0) &&
matchesBytes(buffer, MAGIC_SIGNATURES.webp, 8)
) {
detectedType = "image/webp";
extension = "webp";
}
if (!detectedType) {
return {
valid: false,
error: "Unsupported or invalid image format. Only authentic JPEG, PNG, or WebP images are permitted.",
};
}
return {
valid: true,
mimeType: detectedType,
extension,
sanitizedBase64: `data:${detectedType};base64,${buffer.toString("base64")}`,
sizeBytes: buffer.length,
};
}
/**
* Extracts and normalizes the client IP address from request headers
*/
export function getClientIp(req: NextRequest): string {
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) {
const firstIp = forwardedFor.split(",")[0].trim();
if (firstIp) return firstIp;
}
const realIp = req.headers.get("x-real-ip");
if (realIp && realIp.trim()) {
return realIp.trim();
}
return "127.0.0.1";
}
/**
* Constant-time string comparison using SHA-256 digests to prevent timing attacks
*/
export function constantTimeCompare(a: string, b: string): boolean {
if (typeof a !== "string" || typeof b !== "string") return false;
const hashA = crypto.createHash("sha256").update(a).digest();
const hashB = crypto.createHash("sha256").update(b).digest();
return crypto.timingSafeEqual(hashA, hashB);
}
/**
* In-memory brute-force protection for sensitive endpoints (e.g. portal login)
*/
interface AuthAttemptRecord {
failedAttempts: number;
lockedUntil: number;
firstAttemptAt: number;
}
const authAttemptsStore = new Map<string, AuthAttemptRecord>();
export function checkAuthRateLimit(
ip: string,
maxFailures: number = 5,
lockoutMinutes: number = 15
): { allowed: boolean; remainingAttempts: number; lockoutSeconds: number } {
const now = Date.now();
const record = authAttemptsStore.get(ip);
if (!record) {
return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 };
}
// If currently locked out
if (record.lockedUntil > now) {
const lockoutSeconds = Math.ceil((record.lockedUntil - now) / 1000);
return { allowed: false, remainingAttempts: 0, lockoutSeconds };
}
// If lockout or window has expired (15 minutes), reset
const windowMs = lockoutMinutes * 60 * 1000;
if (now - record.firstAttemptAt > windowMs) {
authAttemptsStore.delete(ip);
return { allowed: true, remainingAttempts: maxFailures, lockoutSeconds: 0 };
}
const remainingAttempts = Math.max(0, maxFailures - record.failedAttempts);
return {
allowed: record.failedAttempts < maxFailures,
remainingAttempts,
lockoutSeconds: 0,
};
}
export function recordAuthFailure(
ip: string,
maxFailures: number = 5,
lockoutMinutes: number = 15
): void {
const now = Date.now();
const record = authAttemptsStore.get(ip);
if (!record) {
authAttemptsStore.set(ip, {
failedAttempts: 1,
lockedUntil: 0,
firstAttemptAt: now,
});
} else {
record.failedAttempts += 1;
if (record.failedAttempts >= maxFailures) {
record.lockedUntil = now + lockoutMinutes * 60 * 1000;
}
}
}
export function recordAuthSuccess(ip: string): void {
authAttemptsStore.delete(ip);
}
+464
View File
@@ -0,0 +1,464 @@
import fs from "fs";
import path from "path";
import { validateAndSanitizeImage } from "@/lib/security";
export interface GenerationRecord {
id: string;
timestamp: string; // ISO 8601 string
dateStr: string; // YYYY-MM-DD for fast grouping
styleId: string;
styleName: string;
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
model: string;
durationMs: number;
status: "success" | "refused" | "error";
imageUrl?: string;
imageFilename?: string;
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
errorMessage?: string;
refusalReason?: string;
clientId?: string;
}
export interface StatsSummary {
period: string;
fromDate?: string;
toDate?: string;
totalAttempts: number;
successCount: number;
refusedCount: number;
errorCount: number;
successRate: number;
avgDurationMs: number;
dailyTrends: {
date: string;
total: number;
success: number;
refused: number;
error: number;
}[];
styleBreakdown: {
styleId: string;
styleName: string;
count: number;
percentage: number;
}[];
providerBreakdown: {
provider: string;
model: string;
count: number;
percentage: number;
}[];
images: GenerationRecord[];
errorLogs: GenerationRecord[];
}
const DATA_DIR = path.join(process.cwd(), "data");
const IMAGES_DIR = path.join(DATA_DIR, "images");
const RECORDS_FILE = path.join(DATA_DIR, "records.json");
const BACKUP_FILE = path.join(DATA_DIR, "records.json.bak");
function ensureDirectories() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(IMAGES_DIR)) {
fs.mkdirSync(IMAGES_DIR, { recursive: true });
}
if (!fs.existsSync(RECORDS_FILE)) {
fs.writeFileSync(RECORDS_FILE, JSON.stringify([], null, 2), "utf8");
}
}
/**
* Mutex / Write Queue to prevent concurrent write collisions
*/
let writeQueuePromise: Promise<void> = Promise.resolve();
function queueWrite(task: () => Promise<void> | void): Promise<void> {
const next = writeQueuePromise.then(() => task()).catch((err) => {
console.error("[Storage Mutex] Write task error:", err);
});
writeQueuePromise = next;
return next;
}
/**
* Load all records from disk safely with automatic backup recovery
*/
export function getAllRecords(): GenerationRecord[] {
ensureDirectories();
try {
const raw = fs.readFileSync(RECORDS_FILE, "utf8");
if (!raw.trim()) {
return recoverFromBackup();
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
console.warn("[Storage] records.json is not an array, attempting backup recovery...");
return recoverFromBackup();
}
return parsed;
} catch (err) {
console.error("[Storage] JSON parse error in records.json, attempting backup recovery:", err);
return recoverFromBackup();
}
}
/**
* Recover from backup file if primary file was corrupted
*/
function recoverFromBackup(): GenerationRecord[] {
try {
if (fs.existsSync(BACKUP_FILE)) {
const backupRaw = fs.readFileSync(BACKUP_FILE, "utf8");
const parsed = JSON.parse(backupRaw);
if (Array.isArray(parsed)) {
console.warn("[Storage] Successfully recovered records from records.json.bak!");
// Re-write valid records back to records.json
fs.writeFileSync(RECORDS_FILE, backupRaw, "utf8");
return parsed;
}
}
} catch (backupErr) {
console.error("[Storage] Backup recovery failed:", backupErr);
}
return [];
}
/**
* Persist records to disk atomically with fsync and backup rotation
*/
async function saveAllRecordsAtomic(records: GenerationRecord[]): Promise<void> {
await queueWrite(() => {
ensureDirectories();
const serialized = JSON.stringify(records, null, 2);
// Safety check: Never write invalid or empty string if we had records
if (!serialized || serialized.length < 2) {
console.error("[Storage] Refusing to write invalid empty JSON payload.");
return;
}
// 1. Maintain backup copy of current good records file before replacing
if (fs.existsSync(RECORDS_FILE)) {
try {
const currentContent = fs.readFileSync(RECORDS_FILE, "utf8");
if (currentContent.trim().length > 2) {
fs.writeFileSync(BACKUP_FILE, currentContent, "utf8");
}
} catch (backupErr) {
console.warn("[Storage] Failed to update backup copy:", backupErr);
}
}
// 2. Write to unique temporary file
const tempFile = path.join(
DATA_DIR,
`records.${Date.now()}_${Math.random().toString(36).substring(2, 8)}.tmp`
);
const fd = fs.openSync(tempFile, "w");
fs.writeSync(fd, serialized, 0, "utf8");
// Ensure data is physically flushed to disk platter/SSD before rename
fs.fsyncSync(fd);
fs.closeSync(fd);
// 3. Atomic rename replaces the destination file instantaneously
fs.renameSync(tempFile, RECORDS_FILE);
});
}
/**
* Save an image file from base64 or URL buffer safely with binary validation
*/
export async function persistImageFile(
id: string,
imageInput: string
): Promise<{ imageUrl: string; filename: string }> {
ensureDirectories();
// Sanitize id to alphanumeric characters, hyphens, and underscores only
const safeId = id.replace(/[^a-zA-Z0-9_-]/g, "");
if (!safeId) {
return { imageUrl: imageInput, filename: "" };
}
// If it's a data URL or raw base64
if (imageInput.startsWith("data:image/") || imageInput.startsWith("data:application/")) {
const validation = validateAndSanitizeImage(imageInput, 15 * 1024 * 1024);
if (validation.valid && validation.extension) {
const ext = validation.extension; // Strictly "jpg" | "png" | "webp"
const filename = `${safeId}.${ext}`;
const filePath = path.join(IMAGES_DIR, filename);
const base64Data = validation.sanitizedBase64!.split(",")[1];
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64"));
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
try {
const { applyWatermarkToFile } = await import("@/lib/watermark");
await applyWatermarkToFile(filePath);
} catch (wmErr) {
console.warn("[Storage] Watermark application skipped:", wmErr);
}
return { imageUrl: `/api/images/${filename}`, filename };
} else {
console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`);
return { imageUrl: imageInput, filename: "" };
}
}
// If it's a remote URL
if (imageInput.startsWith("http://") || imageInput.startsWith("https://")) {
try {
const parsedUrl = new URL(imageInput);
const hostname = parsedUrl.hostname.toLowerCase();
// Block SSRF loopback / private addresses
const isPrivateOrLoopback =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname.startsWith("10.") ||
hostname.startsWith("192.168.") ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) ||
hostname.startsWith("169.254.");
if (isPrivateOrLoopback) {
console.warn(`[Storage SSRF Blocked] Attempted fetch to private/local address: ${hostname}`);
return { imageUrl: imageInput, filename: "" };
}
const res = await fetch(imageInput);
if (res.ok) {
const buffer = Buffer.from(await res.arrayBuffer());
// Validate downloaded buffer via data URL representation
const validation = validateAndSanitizeImage(
`data:image/png;base64,${buffer.toString("base64")}`,
15 * 1024 * 1024
);
if (validation.valid && validation.extension) {
const filename = `${safeId}.${validation.extension}`;
const filePath = path.join(IMAGES_DIR, filename);
fs.writeFileSync(filePath, buffer);
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
try {
const { applyWatermarkToFile } = await import("@/lib/watermark");
await applyWatermarkToFile(filePath);
} catch (wmErr) {
console.warn("[Storage] Watermark application skipped:", wmErr);
}
return { imageUrl: `/api/images/${filename}`, filename };
} else {
console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`);
}
}
} catch (e) {
console.warn("[Storage] Could not fetch remote image to cache locally, storing direct URL:", e);
}
return { imageUrl: imageInput, filename: "" };
}
// Local sample asset
return { imageUrl: imageInput, filename: "" };
}
/**
* Record a generation event (both successes, safety refusals, and server errors)
*/
export async function logGenerationEvent(data: {
styleId: string;
styleName: string;
provider: "gemini" | "openrouter" | "demo_mock" | "unknown";
model: string;
durationMs: number;
status: "success" | "refused" | "error";
rawImageUrl?: string;
errorType?: "safety_refusal" | "provider_error" | "rate_limit" | "server_error";
errorMessage?: string;
refusalReason?: string;
clientId?: string;
}): Promise<GenerationRecord> {
const now = new Date();
const id = `gen_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`;
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
let imageUrl = data.rawImageUrl;
let imageFilename = "";
if (data.status === "success" && data.rawImageUrl) {
const persisted = await persistImageFile(id, data.rawImageUrl);
imageUrl = persisted.imageUrl;
imageFilename = persisted.filename;
}
const record: GenerationRecord = {
id,
timestamp: now.toISOString(),
dateStr,
styleId: data.styleId,
styleName: data.styleName,
provider: data.provider,
model: data.model,
durationMs: data.durationMs,
status: data.status,
imageUrl,
imageFilename,
errorType: data.errorType,
errorMessage: data.errorMessage,
refusalReason: data.refusalReason,
clientId: data.clientId,
};
try {
const existing = getAllRecords();
existing.unshift(record); // Prepend so newest is first
await saveAllRecordsAtomic(existing);
} catch (err) {
console.error("[Storage] Failed to append record:", err);
}
return record;
}
/**
* Query statistics with date filtering
*/
export function getGenerationStats(query: {
period?: "day" | "week" | "month" | "all" | "custom";
fromDate?: string; // YYYY-MM-DD
toDate?: string; // YYYY-MM-DD
}): StatsSummary {
const records = getAllRecords();
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const period = query.period || "week";
let filtered = records;
let computedFrom = query.fromDate;
let computedTo = query.toDate;
if (period === "day") {
computedFrom = todayStr;
computedTo = todayStr;
filtered = records.filter((r) => r.dateStr === todayStr);
} else if (period === "week") {
const sevenDaysAgo = new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000);
computedFrom = sevenDaysAgo.toISOString().slice(0, 10);
computedTo = todayStr;
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
} else if (period === "month") {
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
computedFrom = thirtyDaysAgo.toISOString().slice(0, 10);
computedTo = todayStr;
filtered = records.filter((r) => r.dateStr >= computedFrom! && r.dateStr <= computedTo!);
} else if (period === "custom") {
if (query.fromDate) {
filtered = filtered.filter((r) => r.dateStr >= query.fromDate!);
}
if (query.toDate) {
filtered = filtered.filter((r) => r.dateStr <= query.toDate!);
}
}
const totalAttempts = filtered.length;
const successCount = filtered.filter((r) => r.status === "success").length;
const refusedCount = filtered.filter((r) => r.status === "refused").length;
const errorCount = filtered.filter((r) => r.status === "error").length;
const successRate = totalAttempts > 0 ? Math.round((successCount / totalAttempts) * 1000) / 10 : 0;
const successfulRecords = filtered.filter((r) => r.status === "success");
const totalDuration = successfulRecords.reduce((sum, r) => sum + (r.durationMs || 0), 0);
const avgDurationMs = successfulRecords.length > 0 ? Math.round(totalDuration / successfulRecords.length) : 0;
// Daily Trends aggregation
const dailyMap = new Map<string, { total: number; success: number; refused: number; error: number }>();
// If week or day or month, pre-populate all dates in range so charts are continuous
if (computedFrom && computedTo) {
const start = new Date(computedFrom);
const end = new Date(computedTo);
const cur = new Date(start);
while (cur <= end) {
const d = cur.toISOString().slice(0, 10);
dailyMap.set(d, { total: 0, success: 0, refused: 0, error: 0 });
cur.setDate(cur.getDate() + 1);
}
}
for (const r of filtered) {
const entry = dailyMap.get(r.dateStr) || { total: 0, success: 0, refused: 0, error: 0 };
entry.total += 1;
if (r.status === "success") entry.success += 1;
else if (r.status === "refused") entry.refused += 1;
else if (r.status === "error") entry.error += 1;
dailyMap.set(r.dateStr, entry);
}
const dailyTrends = Array.from(dailyMap.entries())
.map(([date, counts]) => ({ date, ...counts }))
.sort((a, b) => a.date.localeCompare(b.date));
// Style Breakdown
const styleCountMap = new Map<string, { name: string; count: number }>();
for (const r of filtered) {
if (r.styleId) {
const existing = styleCountMap.get(r.styleId) || { name: r.styleName || r.styleId, count: 0 };
existing.count += 1;
styleCountMap.set(r.styleId, existing);
}
}
const styleBreakdown = Array.from(styleCountMap.entries())
.map(([styleId, { name, count }]) => ({
styleId,
styleName: name,
count,
percentage: totalAttempts > 0 ? Math.round((count / totalAttempts) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
// Provider Breakdown
const providerCountMap = new Map<string, { provider: string; model: string; count: number }>();
for (const r of filtered) {
const key = `${r.provider}::${r.model || "default"}`;
const existing = providerCountMap.get(key) || { provider: r.provider, model: r.model || "default", count: 0 };
existing.count += 1;
providerCountMap.set(key, existing);
}
const providerBreakdown = Array.from(providerCountMap.values())
.map((item) => ({
...item,
percentage: totalAttempts > 0 ? Math.round((item.count / totalAttempts) * 100) : 0,
}))
.sort((a, b) => b.count - a.count);
// Filtered Images Gallery (success only)
const images = filtered.filter((r) => r.status === "success" && r.imageUrl);
// Filtered Error & Refusal Logs
const errorLogs = filtered.filter((r) => r.status !== "success");
return {
period,
fromDate: computedFrom,
toDate: computedTo,
totalAttempts,
successCount,
refusedCount,
errorCount,
successRate,
avgDurationMs,
dailyTrends,
styleBreakdown,
providerBreakdown,
images,
errorLogs,
};
}
+70
View File
@@ -0,0 +1,70 @@
import io
from PIL import Image, ImageDraw, ImageFont
import os
def watermark_portrait(image_path_or_buffer):
"""
Overlays:
1. Shanghai Karate Club transparent logo in top-left corner
2. WeChat-scannable school QR code with clean high-contrast backing in bottom-right corner
3. Bottom branding text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
"""
if isinstance(image_path_or_buffer, str):
base = Image.open(image_path_or_buffer).convert("RGBA")
else:
base = Image.open(io.BytesIO(image_path_or_buffer)).convert("RGBA")
bw, bh = base.size
# 1. School Logo (Top-Left)
logo_path = os.path.join(process_cwd(), "public", "assets", "brand", "shanghai_karate_logo.png")
if os.path.exists(logo_path):
logo = Image.open(logo_path).convert("RGBA")
target_logo_w = int(bw * 0.28)
target_logo_h = int(logo.height * (target_logo_w / logo.width))
logo_resized = logo.resize((target_logo_w, target_logo_h), Image.Resampling.LANCZOS)
margin = int(bw * 0.035)
base.paste(logo_resized, (margin, margin), logo_resized)
else:
margin = int(bw * 0.035)
# 2. QR Code (Bottom-Right)
qr_path = os.path.join(process_cwd(), "public", "assets", "brand", "dojo_qr.png")
if os.path.exists(qr_path):
qr = Image.open(qr_path).convert("RGBA")
target_qr_w = int(bw * 0.17)
qr_resized = qr.resize((target_qr_w, target_qr_w), Image.Resampling.LANCZOS)
qr_pad = 5
# Clean white backing ensures WeChat scans perfectly regardless of photo background
qr_bg = Image.new("RGBA", (target_qr_w + qr_pad * 2, target_qr_w + qr_pad * 2), (255, 255, 255, 250))
qr_pos_x = bw - (target_qr_w + qr_pad * 2) - margin
qr_pos_y = bh - (target_qr_w + qr_pad * 2) - int(margin * 1.5)
base.paste(qr_bg, (qr_pos_x, qr_pos_y), qr_bg)
base.paste(qr_resized, (qr_pos_x + qr_pad, qr_pos_y + qr_pad), qr_resized)
# 3. Bottom Text
draw = ImageDraw.Draw(base)
font_path = os.path.join(process_cwd(), "public", "fonts", "ZCOOLKuaiLe.ttf")
font_size = max(14, int(bw * 0.026))
try:
font = ImageFont.truetype(font_path, font_size)
except Exception:
font = ImageFont.load_default()
text = "上海健志空手道馆 · Shanghai Karate Club · Est. 2008"
bbox = draw.textbbox((0, 0), text, font=font)
th = bbox[3] - bbox[1]
tx = margin
ty = bh - int(margin * 1.2) - th
# Subtle drop shadow + crisp warm white text
draw.text((tx + 1, ty + 1), text, font=font, fill=(0, 0, 0, 210))
draw.text((tx, ty), text, font=font, fill=(255, 240, 200, 255))
out_bytes = io.BytesIO()
base.convert("RGB").save(out_bytes, format="JPEG", quality=94)
return out_bytes.getvalue()
def process_cwd():
return r"C:\Users\user\Desktop\katare_helloween"
+35
View File
@@ -0,0 +1,35 @@
import { spawn } from "child_process";
import path from "path";
import fs from "fs";
/**
* Executes the Python watermark processor on an image file on disk.
* Applies:
* 1. School logo without background in top-left corner
* 2. Scannable official WeChat QR code in bottom-right corner
* 3. Bottom text: '上海健志空手道馆 · Shanghai Karate Club · Est. 2008'
*/
export async function applyWatermarkToFile(filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
if (!fs.existsSync(filePath)) {
return resolve();
}
const scriptPath = path.join(process.cwd(), "watermark_processor.py");
const py = spawn("python", [scriptPath, filePath, filePath]);
py.on("close", (code) => {
if (code === 0) {
resolve();
} else {
console.warn(`[Watermark] Python watermark process exited with code ${code}`);
resolve(); // Graceful fallback
}
});
py.on("error", (err) => {
console.warn("[Watermark] Failed to spawn watermark process:", err);
resolve(); // Graceful fallback
});
});
}