perf(booth): optimize assets and result flow
WebP reduces UI payload; JPEG keeps portrait saves compatible.\nFinish ends the visit without prompting another portrait.
No files matched your search
@@ -41,7 +41,7 @@ export async function GET(
|
||||
"Content-Type": contentType,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,6 +171,24 @@ body {
|
||||
border-width: 0 !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) */
|
||||
|
||||
@@ -6,15 +6,14 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"上海健志空手道馆万圣节专属照相馆 (Shanghai Karate Club Halloween Photobooth) - 凝聚万圣气合,定格武道英姿!",
|
||||
icons: {
|
||||
icon: "/assets/brand/shanghai_karate_logo.png",
|
||||
icon: "/assets/brand/shanghai_karate_logo.webp",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
userScalable: true,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#0b0714",
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ConfirmScreen } from "@/components/screens/ConfirmScreen";
|
||||
import { StyleScreen } from "@/components/screens/StyleScreen";
|
||||
import { LoadingScreen } from "@/components/screens/LoadingScreen";
|
||||
import { ResultScreen } from "@/components/screens/ResultScreen";
|
||||
import { FinishedScreen } from "@/components/screens/FinishedScreen";
|
||||
import { RateLimitedScreen } from "@/components/screens/RateLimitedScreen";
|
||||
import { BoothConfig, BoothStyle, ScreenState } from "@/types/booth";
|
||||
import { I18nProvider, useI18n } from "@/lib/i18n";
|
||||
@@ -50,9 +51,11 @@ function PhotoBoothApp() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const previewScreen = params.get("preview") as ScreenState | null;
|
||||
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);
|
||||
setScreen("result");
|
||||
} else if (previewScreen === "finished") {
|
||||
setScreen("finished");
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -63,7 +66,7 @@ function PhotoBoothApp() {
|
||||
if (idleTimerRef.current) {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
}
|
||||
if (!config?.kiosk?.enableAutoReset || screen === "standby") {
|
||||
if (!config?.kiosk?.enableAutoReset || screen === "standby" || screen === "finished") {
|
||||
return;
|
||||
}
|
||||
const timeoutMs = (config.kiosk.idleTimeoutSeconds || 90) * 1000;
|
||||
@@ -178,6 +181,15 @@ function PhotoBoothApp() {
|
||||
setScreen("standby");
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
setCapturedPhoto(null);
|
||||
setSelectedStyle(null);
|
||||
setResultImage(null);
|
||||
setErrorMessage(null);
|
||||
setSafetyWarning(null);
|
||||
setScreen("finished");
|
||||
};
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#090314] text-white">
|
||||
@@ -246,10 +258,12 @@ function PhotoBoothApp() {
|
||||
config={config}
|
||||
resultImageUrl={resultImage}
|
||||
providerInfo={providerInfo}
|
||||
onStartOver={handleStartOver}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
)}
|
||||
|
||||
{screen === "finished" && <FinishedScreen />}
|
||||
|
||||
{screen === "rate_limited" && (
|
||||
<RateLimitedScreen
|
||||
config={config}
|
||||
|
||||
@@ -111,7 +111,7 @@ export const KioskFrame: React.FC<KioskFrameProps> = ({
|
||||
href="https://www.shanghaikarate.com"
|
||||
target="_blank"
|
||||
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)"
|
||||
>
|
||||
<span>上海健志空手道馆 · Shanghai Karate Club · Est. 2008</span>
|
||||
|
||||
@@ -19,10 +19,10 @@ interface MascotGuideProps {
|
||||
}
|
||||
|
||||
const POSE_IMAGES: Record<MascotPose, string> = {
|
||||
bow: "/assets/theme/mascot_bow.png",
|
||||
punch: "/assets/theme/mascot_punch.png",
|
||||
kick: "/assets/theme/mascot_kick.png",
|
||||
zen: "/assets/theme/mascot_zen.png",
|
||||
bow: "/assets/theme/mascot_bow.webp",
|
||||
punch: "/assets/theme/mascot_punch.webp",
|
||||
kick: "/assets/theme/mascot_kick.webp",
|
||||
zen: "/assets/theme/mascot_zen.webp",
|
||||
};
|
||||
|
||||
export const MascotGuide: React.FC<MascotGuideProps> = ({
|
||||
@@ -94,7 +94,7 @@ export const MascotGuide: React.FC<MascotGuideProps> = ({
|
||||
)}
|
||||
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const SpookyAtmosphere: React.FC<SpookyAtmosphereProps> = ({
|
||||
{/* 1. Shanghai Dojo Courtyard Night Background Artwork */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/assets/theme/shanghai_dojo_night_bg.jpg"
|
||||
src="/assets/theme/shanghai_dojo_night_bg.webp"
|
||||
alt="Shanghai Dojo Night Background"
|
||||
className="absolute inset-0 w-full h-full object-cover scale-105"
|
||||
/>
|
||||
|
||||
@@ -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]">
|
||||
<button
|
||||
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]" />
|
||||
<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]">
|
||||
<button
|
||||
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]" />
|
||||
<span>{t(config.labels.confirmButton)}</span>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { Check } from "lucide-react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { BoothConfig } from "@/types/booth";
|
||||
import { useI18n } from "@/lib/i18n";
|
||||
@@ -16,17 +16,17 @@ interface ResultScreenProps {
|
||||
durationMs: number;
|
||||
fallbackTriggered: boolean;
|
||||
};
|
||||
onStartOver?: () => void;
|
||||
onFinish?: () => void;
|
||||
}
|
||||
|
||||
export const ResultScreen: React.FC<ResultScreenProps> = ({
|
||||
config,
|
||||
resultImageUrl,
|
||||
providerInfo,
|
||||
onStartOver,
|
||||
onFinish,
|
||||
}) => {
|
||||
const { t, isWeChat } = useI18n();
|
||||
const [currentUrl, setCurrentUrl] = useState("");
|
||||
const [displayImageUrl, setDisplayImageUrl] = useState(resultImageUrl);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -40,15 +40,74 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
|
||||
// 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 a = document.createElement("a");
|
||||
a.href = resultImageUrl;
|
||||
a.download = `shanghai-karate-spooky-${Date.now()}.jpg`;
|
||||
const isStoredPortrait = resultImageUrl.startsWith("/api/images/");
|
||||
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);
|
||||
a.click();
|
||||
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">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={resultImageUrl}
|
||||
src={displayImageUrl}
|
||||
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 */}
|
||||
{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">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar: Mascot on far bottom-left, Save Button on bottom-right */}
|
||||
<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">
|
||||
{/* Mascot hugging the far bottom-left with speech bubble to the right and lifted higher */}
|
||||
<div className="pointer-events-none -ml-4 sm:-ml-2 -mb-1">
|
||||
{/* Mascot sits above a single row of portrait actions. */}
|
||||
<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">
|
||||
<div className="pointer-events-none -mb-3">
|
||||
<MascotGuide
|
||||
pose="zen"
|
||||
size="md"
|
||||
speechPosition="right"
|
||||
speechClassName="-translate-y-10 sm:-translate-y-8 max-w-[340px] sm:max-w-[400px]"
|
||||
size="sm"
|
||||
speechPosition="top"
|
||||
speechClassName="max-w-[280px] sm:max-w-[320px]"
|
||||
speechText={{
|
||||
en: "Sensational! A true Shanghai Halloween Master!",
|
||||
zh: "太赞了!真正的万圣空手道宗师诞生!",
|
||||
@@ -94,15 +153,23 @@ export const ResultScreen: React.FC<ResultScreenProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button on the bottom-right */}
|
||||
<div className="pb-1.5 shrink-0">
|
||||
<div className="w-full grid grid-cols-2 gap-3 pb-1.5">
|
||||
<button
|
||||
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>
|
||||
</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>
|
||||
|
||||
@@ -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'}`}>
|
||||
{t(labels.welcomeTitle)}
|
||||
</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)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ export const StandbyScreen: React.FC<StandbyScreenProps> = ({
|
||||
<div className="z-10 w-full flex flex-col items-center pb-1">
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
|
||||
@@ -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]">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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 */}
|
||||
<button
|
||||
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>
|
||||
</button>
|
||||
@@ -88,7 +88,7 @@ export const StyleScreen: React.FC<StyleScreenProps> = ({
|
||||
|
||||
{/* Title & Tag */}
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"backgroundType": "image",
|
||||
"backgroundColor": "#0b0714",
|
||||
"backgroundVideoUrl": "",
|
||||
"backgroundImageUrl": "/assets/theme/shanghai_dojo_night_bg.jpg",
|
||||
"backgroundImageUrl": "/assets/theme/shanghai_dojo_night_bg.webp",
|
||||
"primaryColor": "#ff6d00",
|
||||
"accentColor": "#06b6d4",
|
||||
"surfaceColor": "#170f29",
|
||||
"textColor": "#ffffff",
|
||||
"logoUrl": "/assets/brand/shanghai_karate_logo.png",
|
||||
"logoUrl": "/assets/brand/shanghai_karate_logo.webp",
|
||||
"logoWidth": 310
|
||||
},
|
||||
"camera": {
|
||||
@@ -127,20 +127,20 @@
|
||||
"zh": "扫码保存至手机相册"
|
||||
},
|
||||
"downloadLabel": {
|
||||
"en": "Save Portrait",
|
||||
"zh": "保存高清照"
|
||||
"en": "Save",
|
||||
"zh": "保存"
|
||||
},
|
||||
"wechatSaveTip": {
|
||||
"en": "In WeChat: Long press photo above to save directly!",
|
||||
"zh": "微信提示:长按上方图片即可直接保存到相册!"
|
||||
"en": "In WeChat, long-press the photo to save it.",
|
||||
"zh": "微信中长按照片即可保存。"
|
||||
},
|
||||
"printLabel": {
|
||||
"en": "Print Photo",
|
||||
"zh": "打印照片"
|
||||
},
|
||||
"startOverLabel": {
|
||||
"en": "Train Again (Start Over)",
|
||||
"zh": "再拍一张"
|
||||
"en": "Finish",
|
||||
"zh": "完成"
|
||||
},
|
||||
"instagramShareLabel": {
|
||||
"en": "Share Photo",
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
"en": "Extrapolates your posture into an airborne crane stance or soaring crescent kick with a flowing crimson cape.",
|
||||
"zh": "将你的姿态外推升华为兼具美感与爆发力的仙鹤独立型或腾空飞踢,天鹅绒斗篷随风狂舞,夜眺黄浦江画舫。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/bund_vampire_master.jpg",
|
||||
"sampleResult": "/assets/styles/bund_vampire_master.jpg",
|
||||
"thumbnail": "/assets/styles/bund_vampire_master.webp",
|
||||
"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.",
|
||||
"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.",
|
||||
"zh": "将你的姿态推演为极具爆发力的骑马步轰拳或旋风格挡,双拳汇聚万圣流火气合波,背景为圆月东方明珠。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/jack_o_lantern_sensei.jpg",
|
||||
"sampleResult": "/assets/styles/jack_o_lantern_sensei.jpg",
|
||||
"thumbnail": "/assets/styles/jack_o_lantern_sensei.webp",
|
||||
"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.",
|
||||
"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.",
|
||||
"zh": "将你的身形化作极速反弹冲拳或旋风扫腿,青紫电光气合波与水墨书法笔锋撕裂夜空,外滩夜色璀璨。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/ink_wash_cyber_shinobi.jpg",
|
||||
"sampleResult": "/assets/styles/ink_wash_cyber_shinobi.jpg",
|
||||
"thumbnail": "/assets/styles/ink_wash_cyber_shinobi.webp",
|
||||
"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.",
|
||||
"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.",
|
||||
"zh": "将你的动作生动外推为欢快神气的经典型演武,胖嘟嘟的小幽灵们夸张地模仿你的招式在榻榻米上翻滚喝彩。"
|
||||
},
|
||||
"thumbnail": "/assets/styles/ghostly_kiai_dojo.jpg",
|
||||
"sampleResult": "/assets/styles/ghostly_kiai_dojo.jpg",
|
||||
"thumbnail": "/assets/styles/ghostly_kiai_dojo.webp",
|
||||
"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.",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function orchestrateGeneration(
|
||||
// Simulate generation delay (2 seconds)
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
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",
|
||||
modelUsed: "demo-simulator",
|
||||
durationMs: Date.now() - startTime,
|
||||
|
||||
@@ -47,7 +47,7 @@ const defaultConfig: BoothConfig = {
|
||||
accentColor: "#06b6d4",
|
||||
surfaceColor: "#170f29",
|
||||
textColor: "#ffffff",
|
||||
logoUrl: "/assets/brand/shanghai_karate_logo.png",
|
||||
logoUrl: "/assets/brand/shanghai_karate_logo.webp",
|
||||
logoWidth: 260,
|
||||
},
|
||||
camera: {
|
||||
@@ -164,20 +164,20 @@ const defaultConfig: BoothConfig = {
|
||||
zh: "扫码保存至手机相册",
|
||||
},
|
||||
downloadLabel: {
|
||||
en: "Save Portrait",
|
||||
zh: "保存高清照",
|
||||
en: "Save",
|
||||
zh: "保存",
|
||||
},
|
||||
wechatSaveTip: {
|
||||
en: "In WeChat: Long press photo above to save directly!",
|
||||
zh: "微信提示:长按上方图片即可直接保存到相册!",
|
||||
en: "In WeChat, long-press the photo to save it.",
|
||||
zh: "微信中长按照片即可保存。",
|
||||
},
|
||||
printLabel: {
|
||||
en: "Print Photo",
|
||||
zh: "打印照片",
|
||||
},
|
||||
startOverLabel: {
|
||||
en: "Train Again (Start Over)",
|
||||
zh: "再拍一张",
|
||||
en: "Finish",
|
||||
zh: "完成",
|
||||
},
|
||||
instagramShareLabel: {
|
||||
en: "Share Photo",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import sharp from "sharp";
|
||||
import { validateAndSanitizeImage } from "@/lib/security";
|
||||
|
||||
export interface GenerationRecord {
|
||||
@@ -202,26 +203,34 @@ export async function persistImageFile(
|
||||
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 persistJpeg = async (inputBuffer: Buffer) => {
|
||||
const sourcePath = path.join(IMAGES_DIR, `${safeId}.source`);
|
||||
const filename = `${safeId}.jpg`;
|
||||
const jpegPath = path.join(IMAGES_DIR, filename);
|
||||
fs.writeFileSync(sourcePath, inputBuffer);
|
||||
|
||||
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 {
|
||||
// Apply the existing branding before encoding the final downloadable image.
|
||||
try {
|
||||
const { applyWatermarkToFile } = await import("@/lib/watermark");
|
||||
await applyWatermarkToFile(filePath);
|
||||
await applyWatermarkToFile(sourcePath);
|
||||
} catch (wmErr) {
|
||||
console.warn("[Storage] Watermark application skipped:", wmErr);
|
||||
}
|
||||
|
||||
await sharp(sourcePath).jpeg({ quality: 92, mozjpeg: true }).toFile(jpegPath);
|
||||
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 {
|
||||
console.warn(`[Storage Security Alert] Refused to persist invalid image: ${validation.error}`);
|
||||
return { imageUrl: imageInput, filename: "" };
|
||||
@@ -258,20 +267,8 @@ export async function persistImageFile(
|
||||
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 };
|
||||
if (validation.valid) {
|
||||
return persistJpeg(buffer);
|
||||
} else {
|
||||
console.warn(`[Storage Security Alert] Remote file was not a valid image: ${validation.error}`);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ def watermark_portrait(image_path_or_buffer):
|
||||
bw, bh = base.size
|
||||
|
||||
# 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):
|
||||
logo = Image.open(logo_path).convert("RGBA")
|
||||
target_logo_w = int(bw * 0.28)
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function applyWatermarkToFile(filePath: string): Promise<void> {
|
||||
|
||||
try {
|
||||
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 baseImage = sharp(filePath);
|
||||
|
||||
|
Before Width: | Height: | Size: 688 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 446 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -113,4 +113,5 @@ export type ScreenState =
|
||||
| "style"
|
||||
| "processing"
|
||||
| "result"
|
||||
| "finished"
|
||||
| "rate_limited";
|
||||
@@ -8,7 +8,7 @@ def apply_watermark(input_img_path, output_img_path):
|
||||
bw, bh = base.size
|
||||
|
||||
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")
|
||||
|
||||
margin = int(bw * 0.035)
|
||||
|
||||