perf(booth): optimize assets and result flow

WebP reduces UI payload; JPEG keeps portrait saves compatible.\nFinish ends the visit without prompting another portrait.
This commit is contained in:
Ajasra committed 2026-10-30 00:00:16 +08:00
1 parent 56da9c86d2
commit 6772e0cf37
41 files changed
+225 -97

No files matched your search

+1 -1
View File
@@ -41,7 +41,7 @@ export async function GET(
"Content-Type": contentType, "Content-Type": contentType,
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox", "Content-Security-Policy": "default-src 'none'; sandbox",
"Content-Disposition": `inline; filename="${filename}"`, "Content-Disposition": `${req.nextUrl.searchParams.get("download") === "1" ? "attachment" : "inline"}; filename="${filename}"`,
"Cache-Control": "public, max-age=31536000, immutable", "Cache-Control": "public, max-age=31536000, immutable",
}, },
}); });
+18
View File
@@ -171,6 +171,24 @@ body {
border-width: 0 !important; border-width: 0 !important;
box-shadow: none !important; box-shadow: none !important;
} }
/* Tailwind's 12px text-xs was too small for the touch-first booth screens. */
.kiosk-viewport .text-xs {
font-size: 0.875rem;
line-height: 1.25rem;
}
.kiosk-viewport [class~="text-[10px]"],
.kiosk-viewport [class~="text-[11px]"],
.kiosk-viewport [class~="text-[12px]"] {
font-size: 0.875rem;
line-height: 1.25rem;
}
.kiosk-viewport .kiosk-footer-label {
font-size: 0.625rem !important;
line-height: 0.875rem !important;
}
} }
/* Fullscreen Mode URL parameter reset (?mode=fullscreen) */ /* Fullscreen Mode URL parameter reset (?mode=fullscreen) */
+2 -3
View File
@@ -6,15 +6,14 @@ export const metadata: Metadata = {
description: description:
"上海健志空手道馆万圣节专属照相馆 (Shanghai Karate Club Halloween Photobooth) - 凝聚万圣气合,定格武道英姿!", "上海健志空手道馆万圣节专属照相馆 (Shanghai Karate Club Halloween Photobooth) - 凝聚万圣气合,定格武道英姿!",
icons: { icons: {
icon: "/assets/brand/shanghai_karate_logo.png", icon: "/assets/brand/shanghai_karate_logo.webp",
}, },
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
width: "device-width", width: "device-width",
initialScale: 1, initialScale: 1,
maximumScale: 1, userScalable: true,
userScalable: false,
viewportFit: "cover", viewportFit: "cover",
themeColor: "#0b0714", themeColor: "#0b0714",
}; };
+17 -3
View File
@@ -9,6 +9,7 @@ import { ConfirmScreen } from "@/components/screens/ConfirmScreen";
import { StyleScreen } from "@/components/screens/StyleScreen"; import { StyleScreen } from "@/components/screens/StyleScreen";
import { LoadingScreen } from "@/components/screens/LoadingScreen"; import { LoadingScreen } from "@/components/screens/LoadingScreen";
import { ResultScreen } from "@/components/screens/ResultScreen"; import { ResultScreen } from "@/components/screens/ResultScreen";
import { FinishedScreen } from "@/components/screens/FinishedScreen";
import { RateLimitedScreen } from "@/components/screens/RateLimitedScreen"; import { RateLimitedScreen } from "@/components/screens/RateLimitedScreen";
import { BoothConfig, BoothStyle, ScreenState } from "@/types/booth"; import { BoothConfig, BoothStyle, ScreenState } from "@/types/booth";
import { I18nProvider, useI18n } from "@/lib/i18n"; import { I18nProvider, useI18n } from "@/lib/i18n";
@@ -50,9 +51,11 @@ function PhotoBoothApp() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const previewScreen = params.get("preview") as ScreenState | null; const previewScreen = params.get("preview") as ScreenState | null;
if (previewScreen === "result") { if (previewScreen === "result") {
const previewImg = params.get("image") || "/assets/styles/jack_o_lantern_sensei.jpg"; const previewImg = params.get("image") || "/assets/styles/jack_o_lantern_sensei.webp";
setResultImage(previewImg); setResultImage(previewImg);
setScreen("result"); setScreen("result");
} else if (previewScreen === "finished") {
setScreen("finished");
} }
} }
}) })
@@ -63,7 +66,7 @@ function PhotoBoothApp() {
if (idleTimerRef.current) { if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current); clearTimeout(idleTimerRef.current);
} }
if (!config?.kiosk?.enableAutoReset || screen === "standby") { if (!config?.kiosk?.enableAutoReset || screen === "standby" || screen === "finished") {
return; return;
} }
const timeoutMs = (config.kiosk.idleTimeoutSeconds || 90) * 1000; const timeoutMs = (config.kiosk.idleTimeoutSeconds || 90) * 1000;
@@ -178,6 +181,15 @@ function PhotoBoothApp() {
setScreen("standby"); setScreen("standby");
}; };
const handleFinish = () => {
setCapturedPhoto(null);
setSelectedStyle(null);
setResultImage(null);
setErrorMessage(null);
setSafetyWarning(null);
setScreen("finished");
};
if (!config) { if (!config) {
return ( return (
<div className="flex h-screen w-screen items-center justify-center bg-[#090314] text-white"> <div className="flex h-screen w-screen items-center justify-center bg-[#090314] text-white">
@@ -246,10 +258,12 @@ function PhotoBoothApp() {
config={config} config={config}
resultImageUrl={resultImage} resultImageUrl={resultImage}
providerInfo={providerInfo} providerInfo={providerInfo}
onStartOver={handleStartOver} onFinish={handleFinish}
/> />
)} )}
{screen === "finished" && <FinishedScreen />}
{screen === "rate_limited" && ( {screen === "rate_limited" && (
<RateLimitedScreen <RateLimitedScreen
config={config} config={config}
+1 -1
View File
@@ -111,7 +111,7 @@ export const KioskFrame: React.FC<KioskFrameProps> = ({
href="https://www.shanghaikarate.com" href="https://www.shanghaikarate.com"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-[8.5px] sm:text-[9.5px] text-white/50 hover:text-amber-300 transition-colors whitespace-nowrap overflow-hidden text-ellipsis tracking-wide" className="kiosk-footer-label text-[10px] sm:text-[11px] text-white/70 hover:text-amber-300 transition-colors whitespace-nowrap overflow-hidden text-ellipsis tracking-wide"
title="Shanghai Karate Club (shanghaikarate.com)" title="Shanghai Karate Club (shanghaikarate.com)"
> >
<span>上海健志空手道馆 · Shanghai Karate Club · Est. 2008</span> <span>上海健志空手道馆 · Shanghai Karate Club · Est. 2008</span>
+5 -5
View File
@@ -19,10 +19,10 @@ interface MascotGuideProps {
} }
const POSE_IMAGES: Record<MascotPose, string> = { const POSE_IMAGES: Record<MascotPose, string> = {
bow: "/assets/theme/mascot_bow.png", bow: "/assets/theme/mascot_bow.webp",
punch: "/assets/theme/mascot_punch.png", punch: "/assets/theme/mascot_punch.webp",
kick: "/assets/theme/mascot_kick.png", kick: "/assets/theme/mascot_kick.webp",
zen: "/assets/theme/mascot_zen.png", zen: "/assets/theme/mascot_zen.webp",
}; };
export const MascotGuide: React.FC<MascotGuideProps> = ({ export const MascotGuide: React.FC<MascotGuideProps> = ({
@@ -94,7 +94,7 @@ export const MascotGuide: React.FC<MascotGuideProps> = ({
)} )}
<p <p
className={`text-amber-100 font-extrabold text-[12px] sm:text-[14px] leading-snug drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] ${ className={`text-amber-100 font-extrabold text-sm leading-snug drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)] ${
language === "en" ? "font-cinzel" : "font-dojo" language === "en" ? "font-cinzel" : "font-dojo"
}`} }`}
> >
+1 -1
View File
@@ -18,7 +18,7 @@ export const SpookyAtmosphere: React.FC<SpookyAtmosphereProps> = ({
{/* 1. Shanghai Dojo Courtyard Night Background Artwork */} {/* 1. Shanghai Dojo Courtyard Night Background Artwork */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src="/assets/theme/shanghai_dojo_night_bg.jpg" src="/assets/theme/shanghai_dojo_night_bg.webp"
alt="Shanghai Dojo Night Background" alt="Shanghai Dojo Night Background"
className="absolute inset-0 w-full h-full object-cover scale-105" className="absolute inset-0 w-full h-full object-cover scale-105"
/> />
+2 -2
View File
@@ -27,7 +27,7 @@ export const ConfirmScreen: React.FC<ConfirmScreenProps> = ({
<div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]"> <div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]">
<button <button
onClick={onRetake} onClick={onRetake}
className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-xs cursor-pointer shadow-md" className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-sm cursor-pointer shadow-md"
> >
<ArrowLeft size={16} className="stroke-[3]" /> <ArrowLeft size={16} className="stroke-[3]" />
<span>{t(config.labels.retakeButton)}</span> <span>{t(config.labels.retakeButton)}</span>
@@ -63,7 +63,7 @@ export const ConfirmScreen: React.FC<ConfirmScreenProps> = ({
<div className="w-full max-w-sm flex items-center justify-center py-2 z-20 min-h-[70px]"> <div className="w-full max-w-sm flex items-center justify-center py-2 z-20 min-h-[70px]">
<button <button
onClick={onConfirm} onClick={onConfirm}
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg active:scale-95" className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg active:scale-95"
> >
<Check size={17} className="stroke-[3]" /> <Check size={17} className="stroke-[3]" />
<span>{t(config.labels.confirmButton)}</span> <span>{t(config.labels.confirmButton)}</span>
+32
View File
@@ -0,0 +1,32 @@
"use client";
import React from "react";
import { Check } from "lucide-react";
import { MascotGuide } from "@/components/MascotGuide";
import { useI18n } from "@/lib/i18n";
export const FinishedScreen: React.FC = () => {
const { t } = useI18n();
return (
<div className="relative flex-1 flex flex-col items-center justify-center gap-5 p-6 text-center select-none">
<div className="w-16 h-16 rounded-full bg-emerald-500/15 border-2 border-emerald-400/70 text-emerald-300 flex items-center justify-center shadow-[0_0_28px_rgba(52,211,153,0.2)]">
<Check size={34} className="stroke-[3]" />
</div>
<div className="max-w-sm space-y-3">
<h1 className="text-2xl sm:text-3xl font-black text-amber-100 drop-shadow-[0_3px_8px_rgba(0,0,0,0.9)]">
{t({ en: "Thanks for visiting!", zh: "感谢到访!" })}
</h1>
<p className="text-base leading-relaxed font-semibold text-amber-100/90">
{t({
en: "Your portrait is ready to take with you. You can close this page now.",
zh: "您的万圣武道照已准备好带回家,现在可以关闭此页面。",
})}
</p>
</div>
<MascotGuide pose="bow" size="sm" animate={false} />
</div>
);
};
+90 -23
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Download } from "lucide-react"; import { Check } from "lucide-react";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { BoothConfig } from "@/types/booth"; import { BoothConfig } from "@/types/booth";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
@@ -16,17 +16,17 @@ interface ResultScreenProps {
durationMs: number; durationMs: number;
fallbackTriggered: boolean; fallbackTriggered: boolean;
}; };
onStartOver?: () => void; onFinish?: () => void;
} }
export const ResultScreen: React.FC<ResultScreenProps> = ({ export const ResultScreen: React.FC<ResultScreenProps> = ({
config, config,
resultImageUrl, resultImageUrl,
providerInfo, providerInfo,
onStartOver, onFinish,
}) => { }) => {
const { t, isWeChat } = useI18n(); const { t, isWeChat } = useI18n();
const [currentUrl, setCurrentUrl] = useState(""); const [displayImageUrl, setDisplayImageUrl] = useState(resultImageUrl);
useEffect(() => { useEffect(() => {
try { try {
@@ -40,15 +40,74 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
// Ignore // Ignore
} }
if (typeof window !== "undefined") {
setCurrentUrl(window.location.href);
}
}, []); }, []);
useEffect(() => {
let cancelled = false;
let jpegObjectUrl: string | undefined;
setDisplayImageUrl(resultImageUrl);
const prepareJpegForDisplay = async () => {
try {
const response = await fetch(resultImageUrl);
if (!response.ok) return;
const sourceBlob = await response.blob();
if (sourceBlob.type.toLowerCase() === "image/jpeg") return;
const bitmap = await createImageBitmap(sourceBlob);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) {
bitmap.close();
return;
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0);
bitmap.close();
const jpegBlob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("Could not encode portrait as JPEG"));
}, "image/jpeg", 0.92);
});
jpegObjectUrl = URL.createObjectURL(jpegBlob);
if (cancelled) {
URL.revokeObjectURL(jpegObjectUrl);
} else {
setDisplayImageUrl(jpegObjectUrl);
}
} catch {
// Keep the source image when the browser cannot fetch or encode it.
}
};
void prepareJpegForDisplay();
return () => {
cancelled = true;
if (jpegObjectUrl) URL.revokeObjectURL(jpegObjectUrl);
};
}, [resultImageUrl]);
const handleDownload = () => { const handleDownload = () => {
const a = document.createElement("a"); const a = document.createElement("a");
a.href = resultImageUrl; const isStoredPortrait = resultImageUrl.startsWith("/api/images/");
a.download = `shanghai-karate-spooky-${Date.now()}.jpg`; const isStoredJpeg = isStoredPortrait && /\.jpe?g(?:[?#]|$)/i.test(resultImageUrl);
a.href = isStoredJpeg
? `${resultImageUrl}?download=1`
: displayImageUrl;
const extension = isStoredJpeg
? "jpg"
: displayImageUrl.startsWith("blob:")
? "jpg"
: resultImageUrl.match(/\.(jpe?g|png|webp)(?:[?#]|$)/i)?.[1] || "webp";
a.download = `shanghai-karate-spooky-${Date.now()}.${extension}`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
@@ -63,30 +122,30 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
<div className="relative w-full max-w-[275px] sm:max-w-[310px] mx-auto aspect-[9/16] rounded-2xl sm:rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-auto"> <div className="relative w-full max-w-[275px] sm:max-w-[310px] mx-auto aspect-[9/16] rounded-2xl sm:rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-auto">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={resultImageUrl} src={displayImageUrl}
alt="Halloween Karate Result" alt="Halloween Karate Result"
className="w-full h-full object-cover select-none" className="w-full h-full object-cover select-auto"
style={{ WebkitTouchCallout: "default" }}
/> />
{/* WeChat Long-Press Save Floating Badge */} {/* WeChat Long-Press Save Floating Badge */}
{isWeChat && ( {isWeChat && (
<div className="absolute top-2 inset-x-3 py-1 px-3 rounded-full bg-black/75 backdrop-blur-sm border border-amber-400/50 text-center"> <div className="absolute top-2 inset-x-3 py-1 px-3 rounded-full bg-black/75 backdrop-blur-sm border border-amber-400/50 text-center">
<span className="text-[10px] sm:text-[11px] font-bold text-amber-300"> <span className="text-xs font-bold text-amber-300">
👉 {t(config.labels.wechatSaveTip)} 👉 {t(config.labels.wechatSaveTip)}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* Bottom Bar: Mascot on far bottom-left, Save Button on bottom-right */} {/* Mascot sits above a single row of portrait actions. */}
<div className="w-full max-w-[340px] sm:max-w-md mx-auto flex items-end justify-between z-30 -mt-10 sm:-mt-12 px-1 pb-1"> <div className="w-full max-w-[340px] sm:max-w-md mx-auto flex flex-col items-center z-30 -mt-6 px-1 pb-1">
{/* Mascot hugging the far bottom-left with speech bubble to the right and lifted higher */} <div className="pointer-events-none -mb-3">
<div className="pointer-events-none -ml-4 sm:-ml-2 -mb-1">
<MascotGuide <MascotGuide
pose="zen" pose="zen"
size="md" size="sm"
speechPosition="right" speechPosition="top"
speechClassName="-translate-y-10 sm:-translate-y-8 max-w-[340px] sm:max-w-[400px]" speechClassName="max-w-[280px] sm:max-w-[320px]"
speechText={{ speechText={{
en: "Sensational! A true Shanghai Halloween Master!", en: "Sensational! A true Shanghai Halloween Master!",
zh: "太赞了!真正的万圣空手道宗师诞生!", zh: "太赞了!真正的万圣空手道宗师诞生!",
@@ -94,15 +153,23 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
/> />
</div> </div>
{/* Save Button on the bottom-right */} <div className="w-full grid grid-cols-2 gap-3 pb-1.5">
<div className="pb-1.5 shrink-0">
<button <button
onClick={handleDownload} onClick={handleDownload}
className="py-2 px-3.5 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center space-x-1.5 uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer" className="min-h-11 w-full justify-center px-3 game-btn-gold font-extrabold text-sm tracking-wider flex items-center shadow-lg active:scale-95 cursor-pointer"
> >
<Download size={15} className="stroke-[2.5]" />
<span>{t(config.labels.downloadLabel)}</span> <span>{t(config.labels.downloadLabel)}</span>
</button> </button>
{onFinish && (
<button
onClick={onFinish}
className="min-h-11 w-full justify-center px-3 game-btn-dark text-sm font-bold tracking-wide flex items-center space-x-1.5 shadow-lg active:scale-95 cursor-pointer"
>
<Check size={16} className="stroke-[3]" />
<span>{t(config.labels.startOverLabel)}</span>
</button>
)}
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -29,7 +29,7 @@ export const StandbyScreen: React.FC<StandbyScreenProps> = ({
<h2 className={`text-2xl sm:text-3xl md:text-4xl font-black text-amber-100 tracking-wider drop-shadow-[0_4px_12px_rgba(0,0,0,0.95)] ${language === 'en' ? 'font-cinzel' : 'font-dojo'}`}> <h2 className={`text-2xl sm:text-3xl md:text-4xl font-black text-amber-100 tracking-wider drop-shadow-[0_4px_12px_rgba(0,0,0,0.95)] ${language === 'en' ? 'font-cinzel' : 'font-dojo'}`}>
{t(labels.welcomeTitle)} {t(labels.welcomeTitle)}
</h2> </h2>
<p className="text-amber-400/95 font-semibold text-[11px] sm:text-xs tracking-widest uppercase drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]"> <p className="text-amber-400/95 font-semibold text-xs sm:text-xs tracking-widest uppercase drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]">
{t(labels.welcomeSubtitle)} {t(labels.welcomeSubtitle)}
</p> </p>
</div> </div>
@@ -51,7 +51,7 @@ export const StandbyScreen: React.FC<StandbyScreenProps> = ({
<div className="z-10 w-full flex flex-col items-center pb-1"> <div className="z-10 w-full flex flex-col items-center pb-1">
<button <button
onClick={onStart} onClick={onStart}
className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-xs sm:text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg" className="w-full max-w-[210px] sm:max-w-[240px] py-2 sm:py-2.5 px-4 game-btn-gold font-extrabold text-sm tracking-wider flex items-center justify-center space-x-2 text-center select-none uppercase font-cinzel shadow-lg"
> >
<span>{t(labels.welcomeCta)}</span> <span>{t(labels.welcomeCta)}</span>
</button> </button>
+3 -3
View File
@@ -37,7 +37,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
<div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]"> <div className="w-full flex items-center justify-between px-1 mb-1 z-30 min-h-[40px]">
<button <button
onClick={onBack} onClick={onBack}
className="p-2 px-3 game-btn-dark flex items-center space-x-1 font-bold text-xs shrink-0 cursor-pointer shadow-md" className="p-2 px-3 game-btn-dark flex items-center space-x-1 font-bold text-sm shrink-0 cursor-pointer shadow-md"
title="Back" title="Back"
> >
<ArrowLeft size={16} className="stroke-[2.5]" /> <ArrowLeft size={16} className="stroke-[2.5]" />
@@ -47,7 +47,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
{/* Top-Right Confirm / Transform Button - Clean text, no icon */} {/* Top-Right Confirm / Transform Button - Clean text, no icon */}
<button <button
onClick={handleConfirm} onClick={handleConfirm}
className="py-2 px-3.5 game-btn-gold font-extrabold text-xs tracking-wider flex items-center justify-center uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer" className="py-2 px-3.5 game-btn-gold font-extrabold text-sm tracking-wider flex items-center justify-center uppercase font-cinzel shadow-lg active:scale-95 cursor-pointer"
> >
<span>{t(config.labels.confirmStyleButton)}</span> <span>{t(config.labels.confirmStyleButton)}</span>
</button> </button>
@@ -88,7 +88,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
{/* Title & Tag */} {/* Title & Tag */}
<div className="mt-2 text-center w-full px-1"> <div className="mt-2 text-center w-full px-1">
<span className="block text-xs font-bold text-amber-100 truncate drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]"> <span className="block text-sm font-bold text-amber-100 truncate drop-shadow-[0_1px_2px_rgba(0,0,0,0.9)]">
{styleName} {styleName}
</span> </span>
</div> </div>
+8 -8
View File
@@ -5,12 +5,12 @@
"backgroundType": "image", "backgroundType": "image",
"backgroundColor": "#0b0714", "backgroundColor": "#0b0714",
"backgroundVideoUrl": "", "backgroundVideoUrl": "",
"backgroundImageUrl": "/assets/theme/shanghai_dojo_night_bg.jpg", "backgroundImageUrl": "/assets/theme/shanghai_dojo_night_bg.webp",
"primaryColor": "#ff6d00", "primaryColor": "#ff6d00",
"accentColor": "#06b6d4", "accentColor": "#06b6d4",
"surfaceColor": "#170f29", "surfaceColor": "#170f29",
"textColor": "#ffffff", "textColor": "#ffffff",
"logoUrl": "/assets/brand/shanghai_karate_logo.png", "logoUrl": "/assets/brand/shanghai_karate_logo.webp",
"logoWidth": 310 "logoWidth": 310
}, },
"camera": { "camera": {
@@ -127,20 +127,20 @@
"zh": "扫码保存至手机相册" "zh": "扫码保存至手机相册"
}, },
"downloadLabel": { "downloadLabel": {
"en": "Save Portrait", "en": "Save",
"zh": "保存高清照" "zh": "保存"
}, },
"wechatSaveTip": { "wechatSaveTip": {
"en": "In WeChat: Long press photo above to save directly!", "en": "In WeChat, long-press the photo to save it.",
"zh": "微信提示:长按上方图片即可直接保存到相册!" "zh": "微信中长按照片即可保存。"
}, },
"printLabel": { "printLabel": {
"en": "Print Photo", "en": "Print Photo",
"zh": "打印照片" "zh": "打印照片"
}, },
"startOverLabel": { "startOverLabel": {
"en": "Train Again (Start Over)", "en": "Finish",
"zh": "再拍一张" "zh": "完成"
}, },
"instagramShareLabel": { "instagramShareLabel": {
"en": "Share Photo", "en": "Share Photo",
+8 -8
View File
@@ -9,8 +9,8 @@
"en": "Extrapolates your posture into an airborne crane stance or soaring crescent kick with a flowing crimson cape.", "en": "Extrapolates your posture into an airborne crane stance or soaring crescent kick with a flowing crimson cape.",
"zh": "将你的姿态外推升华为兼具美感与爆发力的仙鹤独立型或腾空飞踢,天鹅绒斗篷随风狂舞,夜眺黄浦江画舫。" "zh": "将你的姿态外推升华为兼具美感与爆发力的仙鹤独立型或腾空飞踢,天鹅绒斗篷随风狂舞,夜眺黄浦江画舫。"
}, },
"thumbnail": "/assets/styles/bund_vampire_master.jpg", "thumbnail": "/assets/styles/bund_vampire_master.webp",
"sampleResult": "/assets/styles/bund_vampire_master.jpg", "sampleResult": "/assets/styles/bund_vampire_master.webp",
"prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Deeply analyze the person's posture, weight distribution, arm reach, and inclination in the photo, then BOLDLY EXTRAPOLATE and exaggerate it into an athletic, cinematographic, highly dynamic karate action move matching this style's soaring aerial theme: - If an arm or leg is lifted/stepping -> extrapolate into a soaring aerial karate crane stance (Tsuruashi-dachi) or high flying crescent kick (Mikazuki-geri), one foot perched lightly with martial elegance. - If reaching forward or punching -> extrapolate into a deeply lunged forward strike with their crimson vampire cape billowing dramatically in the river wind. - If arms are bent or neutral -> extrapolate into an intense, crouching cat stance (Nekoashi-dachi) with open knife-hands (Shuto) held in razor-sharp vampire guard. Make the pose feel ALIVE, athletic, and full of cinematic kinetic momentum—NEVER stiff or repetitive. 2. FACIAL IDENTITY: Strictly preserve their exact recognizable facial features, eye shape, smile, nose, chin, and hair. Friends and parents MUST instantly recognize this exact person. STYLING: Translate them into a high-end 3D animated feature film character (Pixar/Hotel Transylvania/DreamWorks). COSTUME: Preserve their costume or gi, adding an elegant flowing crimson velvet vampire cape over their shoulders and an Obi belt around the waist. Genuine friendly smile with cute mini-fangs. Background: Moonlit wooden rooftop dojo overlooking the misty Huangpu River with traditional lantern-lit vintage Shanghai boats beneath an enormous golden full moon and cute cartoon bats fluttering overhead. Warm whimsical cartoon lighting, zero weapons, zero headbands, zero flags, family-friendly.", "prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Deeply analyze the person's posture, weight distribution, arm reach, and inclination in the photo, then BOLDLY EXTRAPOLATE and exaggerate it into an athletic, cinematographic, highly dynamic karate action move matching this style's soaring aerial theme: - If an arm or leg is lifted/stepping -> extrapolate into a soaring aerial karate crane stance (Tsuruashi-dachi) or high flying crescent kick (Mikazuki-geri), one foot perched lightly with martial elegance. - If reaching forward or punching -> extrapolate into a deeply lunged forward strike with their crimson vampire cape billowing dramatically in the river wind. - If arms are bent or neutral -> extrapolate into an intense, crouching cat stance (Nekoashi-dachi) with open knife-hands (Shuto) held in razor-sharp vampire guard. Make the pose feel ALIVE, athletic, and full of cinematic kinetic momentum—NEVER stiff or repetitive. 2. FACIAL IDENTITY: Strictly preserve their exact recognizable facial features, eye shape, smile, nose, chin, and hair. Friends and parents MUST instantly recognize this exact person. STYLING: Translate them into a high-end 3D animated feature film character (Pixar/Hotel Transylvania/DreamWorks). COSTUME: Preserve their costume or gi, adding an elegant flowing crimson velvet vampire cape over their shoulders and an Obi belt around the waist. Genuine friendly smile with cute mini-fangs. Background: Moonlit wooden rooftop dojo overlooking the misty Huangpu River with traditional lantern-lit vintage Shanghai boats beneath an enormous golden full moon and cute cartoon bats fluttering overhead. Warm whimsical cartoon lighting, zero weapons, zero headbands, zero flags, family-friendly.",
"negativePrompt": "boring static standing, flat stiff posture, repetitive generic pose, awkward stiffness, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, photorealistic flesh, creepy horror, scary, bloody, blood, gore, violent, deformed, extra fingers, weapons, swords, katana, japanese flag, headband, text watermark" "negativePrompt": "boring static standing, flat stiff posture, repetitive generic pose, awkward stiffness, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, photorealistic flesh, creepy horror, scary, bloody, blood, gore, violent, deformed, extra fingers, weapons, swords, katana, japanese flag, headband, text watermark"
}, },
@@ -24,8 +24,8 @@
"en": "Extrapolates your posture into a thunderous forward punch or horse stance radiating swirling pumpkin flame orbs.", "en": "Extrapolates your posture into a thunderous forward punch or horse stance radiating swirling pumpkin flame orbs.",
"zh": "将你的姿态推演为极具爆发力的骑马步轰拳或旋风格挡,双拳汇聚万圣流火气合波,背景为圆月东方明珠。" "zh": "将你的姿态推演为极具爆发力的骑马步轰拳或旋风格挡,双拳汇聚万圣流火气合波,背景为圆月东方明珠。"
}, },
"thumbnail": "/assets/styles/jack_o_lantern_sensei.jpg", "thumbnail": "/assets/styles/jack_o_lantern_sensei.webp",
"sampleResult": "/assets/styles/jack_o_lantern_sensei.jpg", "sampleResult": "/assets/styles/jack_o_lantern_sensei.webp",
"prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Extrapolate the user's posture into an explosive, powerful, deeply-grounded karate technique: - If any forward lean or arm push -> extrapolate into an explosive lunging front-punch (Oi-tsuki) with low front stance (Zenkutsu-dachi), driving forward with earth-shattering force. - If hands near waist or body -> extrapolate into a wide, powerful Horse Stance (Kiba-dachi) with dual chambered fists firing outward. - If standing casually or neutral -> extrapolate into an energetic diagonal guard with shoulders turned, knees bent in athletic anticipation, ready to unleash a flame blast. Bursting from their hands are dynamic swirling ribbons of glowing pumpkin embers and magical flame ki shields. The pose must have intense kinetic energy, sharp angles, and dramatic martial action lines. 2. FACIAL IDENTITY: Strictly preserve their exact recognizable face, eye shape, eyelid fold, iris color, nose, smile, hair texture, and glasses. STYLING: High-end 3D animated character (Pixar/DreamWorks aesthetic). COSTUME: Preserve outfit, adding a crisp karate martial belt (Obi). Beside them is a cute animated smiling jack-o'-lantern mimicking their powerful stance. Background: Authentic wooden courtyard dojo with warm lanterns overlooking the glowing Oriental Pearl Tower in Shanghai under a purple twilight harvest moon. Family-friendly, zero weapons, zero headbands.", "prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Extrapolate the user's posture into an explosive, powerful, deeply-grounded karate technique: - If any forward lean or arm push -> extrapolate into an explosive lunging front-punch (Oi-tsuki) with low front stance (Zenkutsu-dachi), driving forward with earth-shattering force. - If hands near waist or body -> extrapolate into a wide, powerful Horse Stance (Kiba-dachi) with dual chambered fists firing outward. - If standing casually or neutral -> extrapolate into an energetic diagonal guard with shoulders turned, knees bent in athletic anticipation, ready to unleash a flame blast. Bursting from their hands are dynamic swirling ribbons of glowing pumpkin embers and magical flame ki shields. The pose must have intense kinetic energy, sharp angles, and dramatic martial action lines. 2. FACIAL IDENTITY: Strictly preserve their exact recognizable face, eye shape, eyelid fold, iris color, nose, smile, hair texture, and glasses. STYLING: High-end 3D animated character (Pixar/DreamWorks aesthetic). COSTUME: Preserve outfit, adding a crisp karate martial belt (Obi). Beside them is a cute animated smiling jack-o'-lantern mimicking their powerful stance. Background: Authentic wooden courtyard dojo with warm lanterns overlooking the glowing Oriental Pearl Tower in Shanghai under a purple twilight harvest moon. Family-friendly, zero weapons, zero headbands.",
"negativePrompt": "boring static standing, stiff arms, identical repetitive pose, frozen mannequin, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, photorealistic composite, flat image, distorted face, monster, scary, blood, gore, weapons, sword, katana, imperial flag, headband, text watermark" "negativePrompt": "boring static standing, stiff arms, identical repetitive pose, frozen mannequin, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, photorealistic composite, flat image, distorted face, monster, scary, blood, gore, weapons, sword, katana, imperial flag, headband, text watermark"
}, },
@@ -39,8 +39,8 @@
"en": "Extrapolates your posture into a high-speed reverse punch or sweeping kick trailed by neon-cyan & purple calligraphic ink.", "en": "Extrapolates your posture into a high-speed reverse punch or sweeping kick trailed by neon-cyan & purple calligraphic ink.",
"zh": "将你的身形化作极速反弹冲拳或旋风扫腿,青紫电光气合波与水墨书法笔锋撕裂夜空,外滩夜色璀璨。" "zh": "将你的身形化作极速反弹冲拳或旋风扫腿,青紫电光气合波与水墨书法笔锋撕裂夜空,外滩夜色璀璨。"
}, },
"thumbnail": "/assets/styles/ink_wash_cyber_shinobi.jpg", "thumbnail": "/assets/styles/ink_wash_cyber_shinobi.webp",
"sampleResult": "/assets/styles/ink_wash_cyber_shinobi.jpg", "sampleResult": "/assets/styles/ink_wash_cyber_shinobi.webp",
"prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Extrapolate the user's body position into a high-velocity, cinematic anime/comic action shot with exaggerated perspective (Spider-Verse kinetic camera angle): - If arms or torso show any lateral movement -> extrapolate into a devastating reverse-punch (Gyaku-tsuki) or back-fist strike (Uraken) with dynamic foreshortening where the front fist appears large and impactful. - If any leg or foot movement -> extrapolate into a mid-air sweeping roundhouse kick (Mawashi-geri) or side kick (Yoko-geri). - If casual standing -> extrapolate into a tense, coiled ready-to-strike crouching guard (Kumite Dachi) with hair and belt fluttering in the shockwave. Dynamic swirling calligraphy ink splashes and electric cyan/purple neon chi lines slice through the air along their movement path. 2. FACIAL IDENTITY: Faithfully reproduce their recognizable face, eyes, smile, and hairstyle. STYLING: High-end 3D animated feature film character in dynamic Spider-Verse meets Pixar style. COSTUME: Maintain their clothing/costume, adding a sleek black karate martial belt (Obi) at the waist. Background: The historic illuminated architecture of the Shanghai Bund along the misty Huangpu River under a dramatic moonlit Halloween sky with playful origami paper bats. High energy, heroic, zero weapons, zero headbands.", "prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Extrapolate the user's body position into a high-velocity, cinematic anime/comic action shot with exaggerated perspective (Spider-Verse kinetic camera angle): - If arms or torso show any lateral movement -> extrapolate into a devastating reverse-punch (Gyaku-tsuki) or back-fist strike (Uraken) with dynamic foreshortening where the front fist appears large and impactful. - If any leg or foot movement -> extrapolate into a mid-air sweeping roundhouse kick (Mawashi-geri) or side kick (Yoko-geri). - If casual standing -> extrapolate into a tense, coiled ready-to-strike crouching guard (Kumite Dachi) with hair and belt fluttering in the shockwave. Dynamic swirling calligraphy ink splashes and electric cyan/purple neon chi lines slice through the air along their movement path. 2. FACIAL IDENTITY: Faithfully reproduce their recognizable face, eyes, smile, and hairstyle. STYLING: High-end 3D animated feature film character in dynamic Spider-Verse meets Pixar style. COSTUME: Maintain their clothing/costume, adding a sleek black karate martial belt (Obi) at the waist. Background: The historic illuminated architecture of the Shanghai Bund along the misty Huangpu River under a dramatic moonlit Halloween sky with playful origami paper bats. High energy, heroic, zero weapons, zero headbands.",
"negativePrompt": "boring static standing, flat stiffness, identical repetitive pose, wooden dummy, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, distorted face, dull colors, weapons, katana, swords, japanese imperial flag, rising sun, headband, hachimaki, scary, bloody, ugly, deformed limbs, watermark" "negativePrompt": "boring static standing, flat stiffness, identical repetitive pose, wooden dummy, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, distorted face, dull colors, weapons, katana, swords, japanese imperial flag, rising sun, headband, hachimaki, scary, bloody, ugly, deformed limbs, watermark"
}, },
@@ -54,8 +54,8 @@
"en": "Extrapolates your posture into an expressive, joyful Kata form with chubby mini ghosts comically exaggerating your move.", "en": "Extrapolates your posture into an expressive, joyful Kata form with chubby mini ghosts comically exaggerating your move.",
"zh": "将你的动作生动外推为欢快神气的经典型演武,胖嘟嘟的小幽灵们夸张地模仿你的招式在榻榻米上翻滚喝彩。" "zh": "将你的动作生动外推为欢快神气的经典型演武,胖嘟嘟的小幽灵们夸张地模仿你的招式在榻榻米上翻滚喝彩。"
}, },
"thumbnail": "/assets/styles/ghostly_kiai_dojo.jpg", "thumbnail": "/assets/styles/ghostly_kiai_dojo.webp",
"sampleResult": "/assets/styles/ghostly_kiai_dojo.jpg", "sampleResult": "/assets/styles/ghostly_kiai_dojo.webp",
"prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Extrapolate the person's pose into an expressive, theatrical, and joyful karate Kata demonstration: - If hands are up or cheering -> extrapolate into a victorious dual-fist skyward Kiai finish with high chin and proud champion posture. - If hands are forward or pointing -> extrapolate into a theatrical knife-hand strike (Shuto-uchi) or double palm thrust (Morote-teisho). - If casual standing -> extrapolate into an exaggerated, theatrical Kata opening salute (Rei / chambered fists) with feet planted wide and a spirited martial expression. GHOST BUDDIES: 3 to 4 chubby cartoon baby ghosts enthusiastically mimic this exaggerated move, with one ghost comically tumbling over trying to copy the stance. Floating candies and glowing jack-o'-lanterns drift in mid-air. 2. FACIAL IDENTITY: Strictly preserve their exact recognizable face, eye shape, smile, teeth, and hair. STYLING: Warm, lovable 3D animated character (Pixar/Hotel Transylvania aesthetic). COSTUME: Tie a martial arts karate belt over their outfit. Background: Inside a cozy traditional wooden Shanghai dojo with glowing carved pumpkin lanterns, paper screens, and a night window showing the Shanghai skyline. Heartwarming, funny, high quality 3D cartoon render, zero weapons, zero flags.", "prompt": "CRITICAL STRICT IDENTITY & DYNAMIC POSE EXTRAPOLATION (ABSOLUTE HIGHEST PRIORITY): 1. DYNAMIC KARATE EXTRAPOLATION (AVOID STATIC / IDENTICAL POSES): Extrapolate the person's pose into an expressive, theatrical, and joyful karate Kata demonstration: - If hands are up or cheering -> extrapolate into a victorious dual-fist skyward Kiai finish with high chin and proud champion posture. - If hands are forward or pointing -> extrapolate into a theatrical knife-hand strike (Shuto-uchi) or double palm thrust (Morote-teisho). - If casual standing -> extrapolate into an exaggerated, theatrical Kata opening salute (Rei / chambered fists) with feet planted wide and a spirited martial expression. GHOST BUDDIES: 3 to 4 chubby cartoon baby ghosts enthusiastically mimic this exaggerated move, with one ghost comically tumbling over trying to copy the stance. Floating candies and glowing jack-o'-lanterns drift in mid-air. 2. FACIAL IDENTITY: Strictly preserve their exact recognizable face, eye shape, smile, teeth, and hair. STYLING: Warm, lovable 3D animated character (Pixar/Hotel Transylvania aesthetic). COSTUME: Tie a martial arts karate belt over their outfit. Background: Inside a cozy traditional wooden Shanghai dojo with glowing carved pumpkin lanterns, paper screens, and a night window showing the Shanghai skyline. Heartwarming, funny, high quality 3D cartoon render, zero weapons, zero flags.",
"negativePrompt": "boring static standing, dull stiffness, identical repetitive pose, frozen expression, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, terrifying, scary ghost, evil demon, blood, gore, horror, distorted face, changed identity, weapons, swords, katana, rising sun, headband, blurry, deformed limbs, watermark" "negativePrompt": "boring static standing, dull stiffness, identical repetitive pose, frozen expression, unrecognizable person, different face, altered facial features, generic cartoon face, changed eye shape, changed skin tone, changed nose, bad likeness, terrifying, scary ghost, evil demon, blood, gore, horror, distorted face, changed identity, weapons, swords, katana, rising sun, headband, blurry, deformed limbs, watermark"
} }
+1 -1
View File
@@ -41,7 +41,7 @@ export async function orchestrateGeneration(
// Simulate generation delay (2 seconds) // Simulate generation delay (2 seconds)
await new Promise((r) => setTimeout(r, 2000)); await new Promise((r) => setTimeout(r, 2000));
return { return {
imageUrl: style.sampleResult || style.thumbnail || "/assets/styles/sample_clay_pink.png", imageUrl: style.sampleResult || style.thumbnail || "/assets/styles/jack_o_lantern_sensei.webp",
providerUsed: "demo_mock", providerUsed: "demo_mock",
modelUsed: "demo-simulator", modelUsed: "demo-simulator",
durationMs: Date.now() - startTime, durationMs: Date.now() - startTime,
+7 -7
View File
@@ -47,7 +47,7 @@ const defaultConfig: BoothConfig = {
accentColor: "#06b6d4", accentColor: "#06b6d4",
surfaceColor: "#170f29", surfaceColor: "#170f29",
textColor: "#ffffff", textColor: "#ffffff",
logoUrl: "/assets/brand/shanghai_karate_logo.png", logoUrl: "/assets/brand/shanghai_karate_logo.webp",
logoWidth: 260, logoWidth: 260,
}, },
camera: { camera: {
@@ -164,20 +164,20 @@ const defaultConfig: BoothConfig = {
zh: "扫码保存至手机相册", zh: "扫码保存至手机相册",
}, },
downloadLabel: { downloadLabel: {
en: "Save Portrait", en: "Save",
zh: "保存高清照", zh: "保存",
}, },
wechatSaveTip: { wechatSaveTip: {
en: "In WeChat: Long press photo above to save directly!", en: "In WeChat, long-press the photo to save it.",
zh: "微信提示:长按上方图片即可直接保存到相册!", zh: "微信中长按照片即可保存。",
}, },
printLabel: { printLabel: {
en: "Print Photo", en: "Print Photo",
zh: "打印照片", zh: "打印照片",
}, },
startOverLabel: { startOverLabel: {
en: "Train Again (Start Over)", en: "Finish",
zh: "再拍一张", zh: "完成",
}, },
instagramShareLabel: { instagramShareLabel: {
en: "Share Photo", en: "Share Photo",
+23 -26
View File
@@ -1,5 +1,6 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import sharp from "sharp";
import { validateAndSanitizeImage } from "@/lib/security"; import { validateAndSanitizeImage } from "@/lib/security";
export interface GenerationRecord { export interface GenerationRecord {
@@ -202,26 +203,34 @@ export async function persistImageFile(
return { imageUrl: imageInput, filename: "" }; return { imageUrl: imageInput, filename: "" };
} }
// If it's a data URL or raw base64 const persistJpeg = async (inputBuffer: Buffer) => {
if (imageInput.startsWith("data:image/") || imageInput.startsWith("data:application/")) { const sourcePath = path.join(IMAGES_DIR, `${safeId}.source`);
const validation = validateAndSanitizeImage(imageInput, 15 * 1024 * 1024); const filename = `${safeId}.jpg`;
if (validation.valid && validation.extension) { const jpegPath = path.join(IMAGES_DIR, filename);
const ext = validation.extension; // Strictly "jpg" | "png" | "webp" fs.writeFileSync(sourcePath, inputBuffer);
const filename = `${safeId}.${ext}`;
const filePath = path.join(IMAGES_DIR, filename);
const base64Data = validation.sanitizedBase64!.split(",")[1]; try {
fs.writeFileSync(filePath, Buffer.from(base64Data, "base64")); // Apply the existing branding before encoding the final downloadable image.
// Automatically apply school branding: logo (top-left), WeChat QR (bottom-right), and footer text
try { try {
const { applyWatermarkToFile } = await import("@/lib/watermark"); const { applyWatermarkToFile } = await import("@/lib/watermark");
await applyWatermarkToFile(filePath); await applyWatermarkToFile(sourcePath);
} catch (wmErr) { } catch (wmErr) {
console.warn("[Storage] Watermark application skipped:", wmErr); console.warn("[Storage] Watermark application skipped:", wmErr);
} }
await sharp(sourcePath).jpeg({ quality: 92, mozjpeg: true }).toFile(jpegPath);
return { imageUrl: `/api/images/${filename}`, filename }; return { imageUrl: `/api/images/${filename}`, filename };
} finally {
if (fs.existsSync(sourcePath)) fs.unlinkSync(sourcePath);
}
};
// 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) {
const base64Data = validation.sanitizedBase64!.split(",")[1];
return persistJpeg(Buffer.from(base64Data, "base64"));
} else { } else {
console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`); console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`);
return { imageUrl: imageInput, filename: "" }; return { imageUrl: imageInput, filename: "" };
@@ -258,20 +267,8 @@ export async function persistImageFile(
15 * 1024 * 1024 15 * 1024 * 1024
); );
if (validation.valid && validation.extension) { if (validation.valid) {
const filename = `${safeId}.${validation.extension}`; return persistJpeg(buffer);
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 { } else {
console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`); console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`);
} }
+1 -1
View File
@@ -17,7 +17,7 @@ def watermark_portrait(image_path_or_buffer):
bw, bh = base.size bw, bh = base.size
# 1. School Logo (Top-Left) # 1. School Logo (Top-Left)
logo_path = os.path.join(process_cwd(), "public", "assets", "brand", "shanghai_karate_logo.png") logo_path = os.path.join(process_cwd(), "public", "assets", "brand", "shanghai_karate_logo.webp")
if os.path.exists(logo_path): if os.path.exists(logo_path):
logo = Image.open(logo_path).convert("RGBA") logo = Image.open(logo_path).convert("RGBA")
target_logo_w = int(bw * 0.28) target_logo_w = int(bw * 0.28)
+1 -1
View File
@@ -16,7 +16,7 @@ export async function applyWatermarkToFile(filePath: string): Promise<void> {
try { try {
const baseDir = process.cwd(); const baseDir = process.cwd();
const logoPath = path.join(baseDir, "public", "assets", "brand", "shanghai_karate_logo.png"); const logoPath = path.join(baseDir, "public", "assets", "brand", "shanghai_karate_logo.webp");
const qrPath = path.join(baseDir, "public", "assets", "brand", "dojo_qr.png"); const qrPath = path.join(baseDir, "public", "assets", "brand", "dojo_qr.png");
const baseImage = sharp(filePath); const baseImage = sharp(filePath);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+1
View File
@@ -113,4 +113,5 @@ export type ScreenState =
| "style" | "style"
| "processing" | "processing"
| "result" | "result"
| "finished"
| "rate_limited"; | "rate_limited";
+1 -1
View File
@@ -8,7 +8,7 @@ def apply_watermark(input_img_path, output_img_path):
bw, bh = base.size bw, bh = base.size
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(base_dir, "public", "assets", "brand", "shanghai_karate_logo.png") logo_path = os.path.join(base_dir, "public", "assets", "brand", "shanghai_karate_logo.webp")
qr_path = os.path.join(base_dir, "public", "assets", "brand", "dojo_qr.png") qr_path = os.path.join(base_dir, "public", "assets", "brand", "dojo_qr.png")
margin = int(bw * 0.035) margin = int(bw * 0.035)