diff --git a/build/images/AudioConverter.png b/build/images/AudioConverter.png new file mode 100644 index 0000000..6a0076d Binary files /dev/null and b/build/images/AudioConverter.png differ diff --git a/build/images/FaceEnhancerNew.png b/build/images/FaceEnhancerNew.png new file mode 100644 index 0000000..cafdc45 Binary files /dev/null and b/build/images/FaceEnhancerNew.png differ diff --git a/build/images/ImageConverter.png b/build/images/ImageConverter.png new file mode 100644 index 0000000..86328df Binary files /dev/null and b/build/images/ImageConverter.png differ diff --git a/build/images/MediaMerger.png b/build/images/MediaMerger.png new file mode 100644 index 0000000..7c5afa6 Binary files /dev/null and b/build/images/MediaMerger.png differ diff --git a/build/images/MediaTrimmer.png b/build/images/MediaTrimmer.png new file mode 100644 index 0000000..3174ba0 Binary files /dev/null and b/build/images/MediaTrimmer.png differ diff --git a/build/images/VideoConverter.png b/build/images/VideoConverter.png new file mode 100644 index 0000000..803f667 Binary files /dev/null and b/build/images/VideoConverter.png differ diff --git a/mobile/src/App.tsx b/mobile/src/App.tsx index 729b0f4..ce3c201 100644 --- a/mobile/src/App.tsx +++ b/mobile/src/App.tsx @@ -3,6 +3,7 @@ import { Routes, Route } from 'react-router-dom' import { MobileLayout } from '@mobile/components/layout/MobileLayout' import { WelcomePage } from '@/pages/WelcomePage' import { FeaturedModelsPage } from '@/pages/FeaturedModelsPage' +import { SmartPlaygroundPage } from '@/pages/SmartPlaygroundPage' import { ModelsPage } from '@/pages/ModelsPage' import { MobilePlaygroundPage } from '@mobile/pages/MobilePlaygroundPage' import { MobileTemplatesPage } from '@mobile/pages/MobileTemplatesPage' @@ -37,6 +38,7 @@ function App() { }> } /> } /> + } /> } /> } /> } /> diff --git a/mobile/src/components/layout/MobileHeader.tsx b/mobile/src/components/layout/MobileHeader.tsx index c2f5b47..7d73fae 100644 --- a/mobile/src/components/layout/MobileHeader.tsx +++ b/mobile/src/components/layout/MobileHeader.tsx @@ -1,8 +1,9 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' -import { ChevronLeft, Zap, Home, Sun, Moon } from 'lucide-react' +import { ChevronLeft, Home, Sun, Moon } from 'lucide-react' import { Button } from '@/components/ui/button' import { useThemeStore } from '@/stores/themeStore' +import { AppLogo } from '@/components/layout/AppLogo' import { cn } from '@/lib/utils' // Map paths to page titles (translation key or plain text prefixed with '!') @@ -59,8 +60,8 @@ export function MobileHeader() { return t('nav.playground') } - // Default to app name - return 'WaveSpeed' + // Default to app name (home page) + return null } const showBackButton = pagesWithBackButton.some(path => @@ -94,11 +95,7 @@ export function MobileHeader() { ) : location.pathname === '/' ? ( -
-
- -
-
+ ) : ( diff --git a/src/components/playground/OutputDisplay.tsx b/src/components/playground/OutputDisplay.tsx index 465430a..1ac1b35 100644 --- a/src/components/playground/OutputDisplay.tsx +++ b/src/components/playground/OutputDisplay.tsx @@ -80,9 +80,10 @@ interface OutputDisplayProps { isLoading: boolean modelId?: string hideGameButton?: boolean + gridLayout?: boolean } -export function OutputDisplay({ prediction, outputs, error, isLoading, modelId, hideGameButton }: OutputDisplayProps) { +export function OutputDisplay({ prediction, outputs, error, isLoading, modelId, hideGameButton, gridLayout }: OutputDisplayProps) { const { t } = useTranslation() const [copiedIndex, setCopiedIndex] = useState(null) const [fullscreenIndex, setFullscreenIndex] = useState(null) @@ -442,7 +443,12 @@ export function OutputDisplay({ prediction, outputs, error, isLoading, modelId, )} {/* Outputs - fill remaining space */} -
+
1 + ? "grid grid-cols-2 md:grid-cols-3 gap-3 auto-rows-min overflow-auto" + : "flex flex-col gap-4" + )}> {outputs.map((output, index) => { const isObject = typeof output === 'object' && output !== null const outputStr = isObject ? JSON.stringify(output, null, 2) : String(output) @@ -454,7 +460,12 @@ export function OutputDisplay({ prediction, outputs, error, isLoading, modelId, return (
1 + ? "aspect-square" + : "flex-1 min-h-0" + )} >
diff --git a/src/components/shared/ProcessingProgress.tsx b/src/components/shared/ProcessingProgress.tsx index 5cf59ed..9200e2b 100644 --- a/src/components/shared/ProcessingProgress.tsx +++ b/src/components/shared/ProcessingProgress.tsx @@ -96,10 +96,12 @@ export function ProcessingProgress({ return null } + const progressValue = showOverall && phases.length > 1 ? overallProgress : currentPhase?.progress || 0 + return ( -
- {/* Single row: phase dots + current phase label + progress + ETA */} -
+
+ {/* Top row: phase dots + label + ETA + percentage */} +
{/* Phase indicators - only show if not too many */} {shouldShowPhaseDots && (
@@ -123,29 +125,24 @@ export function ProcessingProgress({ {/* Current phase label with spinner */} {currentLabel && ( - + {isActive && currentPhase?.status === 'active' && ( - + )} {currentPhase?.status === 'completed' && ( - + )} - {currentLabel} + {currentLabel} {!isComplete && currentPhase?.detail && formatDetail(currentPhase.detail) && ( - + ({formatDetail(currentPhase.detail)}) )} )} - {/* Progress bar - fills remaining space */} -
- 1 ? overallProgress : currentPhase?.progress || 0} - className="h-1.5" - /> -
+ {/* Spacer */} +
{/* Percentage and ETA */}
@@ -153,10 +150,16 @@ export function ProcessingProgress({ ~{eta} )} - {(showOverall && phases.length > 1 ? overallProgress : currentPhase?.progress || 0).toFixed(1)}% + {progressValue.toFixed(1)}%
+ + {/* Progress bar - full width on its own row */} +
) } diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 18edc7c..67e4e6c 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -12,7 +12,7 @@ export function Toaster() { const { toasts } = useToast() return ( - + {toasts.map(function ({ id, title, description, action, ...props }) { return ( diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 4a1bc3e..4b65298 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -934,6 +934,26 @@ "downloadStarted": "بدأ التنزيل", "downloadFailed": "فشل التنزيل" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "محول الفيديو", "description": "تحويل مقاطع الفيديو بين الصيغ باستخدام متصفحك", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d0daa47..59492de 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -938,6 +938,26 @@ "waitingLong": "Lange Wartezeit? Spiel ein Mini-Spiel!", "clickToPlay": "Klicken zum Starten" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Video-Konverter", "description": "Videos zwischen Formaten mit Ihrem Browser konvertieren", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c317bd2..a774ce0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -938,6 +938,26 @@ "waitingLong": "This is taking a while", "clickToPlay": "Tap to play a game while waiting" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Video Converter", "description": "Convert videos between formats using your browser", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 64c0106..fd8a2c3 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -938,6 +938,26 @@ "waitingLong": "¿Esperando mucho? ¡Juega un mini juego!", "clickToPlay": "Haz clic para empezar" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Convertidor de Video", "description": "Convierte videos entre formatos usando tu navegador", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ecdc60f..6e7594b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -938,6 +938,26 @@ "waitingLong": "L'attente est longue ? Jouez à un mini-jeu !", "clickToPlay": "Cliquez pour commencer" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Convertisseur Vidéo", "description": "Convertir des vidéos entre formats avec votre navigateur", diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 632defc..0844a54 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -938,6 +938,26 @@ "waitingLong": "ज़्यादा इंतज़ार? एक मिनी गेम खेलें!", "clickToPlay": "शुरू करने के लिए क्लिक करें" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "वीडियो कनवर्टर", "description": "अपने ब्राउज़र का उपयोग करके वीडियो को फॉर्मेट के बीच कनवर्ट करें", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index f43f05b..65d8fca 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -960,6 +960,26 @@ "waitingLong": "Menunggu lama? Main mini game!", "clickToPlay": "Klik untuk mulai" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Konverter Video", "description": "Konversi video antar format menggunakan browser Anda", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 3e902fd..31a41db 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -938,6 +938,26 @@ "waitingLong": "Attesa troppo lunga? Gioca a un mini gioco!", "clickToPlay": "Clicca per iniziare" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Convertitore Video", "description": "Converti video tra formati usando il tuo browser", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a9e07e..6687e22 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -938,6 +938,26 @@ "waitingLong": "待ち時間が長い?ミニゲームで遊ぼう!", "clickToPlay": "クリックして開始" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "動画コンバーター", "description": "ブラウザで動画形式を変換", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 8645853..2193092 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -938,6 +938,26 @@ "waitingLong": "오래 기다리셨나요? 미니 게임 해보세요!", "clickToPlay": "클릭하여 시작" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "비디오 변환기", "description": "브라우저에서 비디오 형식 변환", diff --git a/src/i18n/locales/ms.json b/src/i18n/locales/ms.json index 68c9713..c5640b9 100644 --- a/src/i18n/locales/ms.json +++ b/src/i18n/locales/ms.json @@ -934,6 +934,26 @@ "downloadStarted": "Muat turun dimulakan", "downloadFailed": "Muat turun gagal" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Penukar Video", "description": "Tukar video antara format menggunakan pelayar anda", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 38856a7..c7778f4 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -938,6 +938,26 @@ "waitingLong": "Esperando demais? Jogue um mini jogo!", "clickToPlay": "Clique para começar" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Conversor de Vídeo", "description": "Converta vídeos entre formatos usando seu navegador", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index e2e300f..feb1857 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -938,6 +938,26 @@ "waitingLong": "Долго ждёте? Сыграйте в мини-игру!", "clickToPlay": "Нажмите, чтобы начать" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Конвертер Видео", "description": "Конвертируйте видео между форматами в браузере", diff --git a/src/i18n/locales/th.json b/src/i18n/locales/th.json index ee3e33a..b607730 100644 --- a/src/i18n/locales/th.json +++ b/src/i18n/locales/th.json @@ -934,6 +934,26 @@ "downloadStarted": "เริ่มดาวน์โหลดแล้ว", "downloadFailed": "ดาวน์โหลดล้มเหลว" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "ตัวแปลงวิดีโอ", "description": "แปลงวิดีโอระหว่างรูปแบบโดยใช้เบราว์เซอร์ของคุณ", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 77e5b14..82360e2 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -938,6 +938,26 @@ "waitingLong": "Çok mu bekliyorsunuz? Mini oyun oynayın!", "clickToPlay": "Başlamak için tıklayın" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Video Dönüştürücü", "description": "Tarayıcınızı kullanarak videoları formatlar arasında dönüştürün", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index b4fdfcb..38d9c48 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -934,6 +934,26 @@ "downloadStarted": "Đã bắt đầu tải xuống", "downloadFailed": "Tải xuống thất bại" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "Trình Chuyển Đổi Video", "description": "Chuyển đổi video giữa các định dạng bằng trình duyệt của bạn", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3d8e3a4..5eb67f4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -938,6 +938,26 @@ "waitingLong": "等待时间较长", "clickToPlay": "点击玩个小游戏打发时间" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "视频转换器", "description": "在浏览器中转换视频格式", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 3c2e45e..171ea37 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -938,6 +938,26 @@ "waitingLong": "等很久了?來局小遊戲!", "clickToPlay": "點擊開始" }, + "smartPlayground": { + "autoDetected": "Auto-detected variant", + "willCall": "Will call", + "toggleSpeed": "Speed", + "toggleMode": "Mode", + "toggleQuality": "Quality", + "speedNormal": "Normal", + "speedFast": "Fast", + "modeNormal": "Normal", + "modeSequential": "Sequential", + "qualityPro": "Pro", + "qualityStd": "Standard", + "qualityUltra": "Ultra", + "qualityMulti": "Multi", + "run": "Run", + "running": "Running...", + "input": "Input", + "output": "Output", + "back": "Back" + }, "videoConverter": { "title": "影片轉換器", "description": "在瀏覽器中轉換影片格式", diff --git a/src/lib/schemaToForm.ts b/src/lib/schemaToForm.ts index b18714c..9e6c137 100644 --- a/src/lib/schemaToForm.ts +++ b/src/lib/schemaToForm.ts @@ -159,10 +159,16 @@ function propertyToField( // Handle x-ui-component: uploader (for zip files etc.) if (prop['x-ui-component'] === 'uploader') { + // If no x-accept, try to infer from field name + let fileAccept = prop['x-accept'] + if (!fileAccept) { + const inferred = detectFileType(name) + fileAccept = inferred?.accept || '*/*' + } return { ...baseField, type: 'file', - accept: prop['x-accept'] || '*/*', + accept: fileAccept, placeholder: prop['x-placeholder'], } } @@ -190,8 +196,8 @@ function propertyToField( } } - // Handle loras fields (including high_noise_loras, low_noise_loras via x-ui-component) - if (prop['x-ui-component'] === 'loras' || (name.toLowerCase() === 'loras' && prop.type === 'array')) { + // Handle loras fields (including high_noise_loras, low_noise_loras) + if (prop['x-ui-component'] === 'loras' || (name.toLowerCase().includes('lora') && prop.type === 'array')) { return { ...baseField, type: 'loras', diff --git a/src/lib/smartFormConfig.ts b/src/lib/smartFormConfig.ts new file mode 100644 index 0000000..4fd77c0 --- /dev/null +++ b/src/lib/smartFormConfig.ts @@ -0,0 +1,291 @@ +export interface SmartFormToggle { + key: string + labelKey: string + options: { value: string; labelKey: string }[] + default: string +} + +export interface SmartFormFamily { + id: string + name: string + provider: string + poster: string + category: 'image' | 'video' | 'other' + variantIds: string[] + primaryVariant: string + toggles: SmartFormToggle[] + resolveVariant: ( + filledFields: Set, + toggleValues: Record + ) => string + mapValues?: ( + values: Record, + resolvedVariantId: string + ) => Record + excludeFields?: string[] +} + +// Helper to check if any file-like field is filled +function hasFileField(filledFields: Set, ...names: string[]): boolean { + return names.some(n => filledFields.has(n)) +} + +function hasImageFilled(filledFields: Set): boolean { + return hasFileField(filledFields, 'image', 'images', 'image_url', 'image_urls', 'input_image') +} + +function hasVideoFilled(filledFields: Set): boolean { + return hasFileField(filledFields, 'video', 'videos', 'video_url', 'video_urls', 'input_video') +} + +function hasLorasFilled(filledFields: Set): boolean { + for (const name of filledFields) { + if (name.toLowerCase().includes('lora')) return true + } + return false +} + +export const SMART_FORM_FAMILIES: SmartFormFamily[] = [ + // 1. Seedream 4.5 + { + id: 'seedream-4.5', + name: 'Seedream 4.5', + provider: 'bytedance', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1764761216479761378_Yy864da9.png', + category: 'image', + variantIds: [ + 'bytedance/seedream-v4.5', + 'bytedance/seedream-v4.5/sequential', + 'bytedance/seedream-v4.5/edit', + 'bytedance/seedream-v4.5/edit-sequential', + ], + primaryVariant: 'bytedance/seedream-v4.5', + toggles: [ + { + key: 'mode', + labelKey: 'smartPlayground.toggleMode', + options: [ + { value: 'normal', labelKey: 'smartPlayground.modeNormal' }, + { value: 'sequential', labelKey: 'smartPlayground.modeSequential' }, + ], + default: 'normal', + }, + ], + resolveVariant(filledFields, toggleValues) { + const hasImage = hasImageFilled(filledFields) + const isSequential = toggleValues.mode === 'sequential' + if (hasImage && isSequential) return 'bytedance/seedream-v4.5/edit-sequential' + if (hasImage) return 'bytedance/seedream-v4.5/edit' + if (isSequential) return 'bytedance/seedream-v4.5/sequential' + return 'bytedance/seedream-v4.5' + }, + }, + + // 2. Seedance 1.5 Pro + { + id: 'seedance-1.5-pro', + name: 'Seedance 1.5 Pro', + provider: 'bytedance', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766494048998434655_qEMLsAI0.png', + category: 'video', + variantIds: [ + 'bytedance/seedance-v1.5-pro/image-to-video', + 'bytedance/seedance-v1.5-pro/image-to-video-fast', + 'bytedance/seedance-v1.5-pro/text-to-video', + 'bytedance/seedance-v1.5-pro/text-to-video-fast', + 'bytedance/seedance-v1.5-pro/video-extend', + 'bytedance/seedance-v1.5-pro/video-extend-fast', + ], + primaryVariant: 'bytedance/seedance-v1.5-pro/image-to-video', + toggles: [ + { + key: 'speed', + labelKey: 'smartPlayground.toggleSpeed', + options: [ + { value: 'normal', labelKey: 'smartPlayground.speedNormal' }, + { value: 'fast', labelKey: 'smartPlayground.speedFast' }, + ], + default: 'normal', + }, + ], + resolveVariant(filledFields, toggleValues) { + const hasVideo = hasVideoFilled(filledFields) + const hasImage = hasImageFilled(filledFields) + const isFast = toggleValues.speed === 'fast' + if (hasVideo) return isFast ? 'bytedance/seedance-v1.5-pro/video-extend-fast' : 'bytedance/seedance-v1.5-pro/video-extend' + if (hasImage) return isFast ? 'bytedance/seedance-v1.5-pro/image-to-video-fast' : 'bytedance/seedance-v1.5-pro/image-to-video' + return isFast ? 'bytedance/seedance-v1.5-pro/text-to-video-fast' : 'bytedance/seedance-v1.5-pro/text-to-video' + }, + }, + + // 3. Wan Spicy + { + id: 'wan-spicy', + name: 'Wan Spicy', + provider: 'wavespeed-ai', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766298334453523753_f975da96.png', + category: 'video', + variantIds: [ + 'wavespeed-ai/wan-2.2-spicy/image-to-video', + 'wavespeed-ai/wan-2.2-spicy/image-to-video-lora', + 'wavespeed-ai/wan-2.2-spicy/video-extend', + 'wavespeed-ai/wan-2.2-spicy/video-extend-lora', + ], + primaryVariant: 'wavespeed-ai/wan-2.2-spicy/image-to-video', + toggles: [], + resolveVariant(filledFields) { + const hasVideo = hasVideoFilled(filledFields) + const hasLoras = hasLorasFilled(filledFields) + if (hasVideo && hasLoras) return 'wavespeed-ai/wan-2.2-spicy/video-extend-lora' + if (hasVideo) return 'wavespeed-ai/wan-2.2-spicy/video-extend' + if (hasLoras) return 'wavespeed-ai/wan-2.2-spicy/image-to-video-lora' + return 'wavespeed-ai/wan-2.2-spicy/image-to-video' + }, + }, + + // 4. Wan Animate + { + id: 'wan-animate', + name: 'Wan Animate', + provider: 'wavespeed-ai', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1758433474532574441_SkTQLIEA.jpeg', + category: 'other', + variantIds: [ + 'wavespeed-ai/wan-2.2/animate', + ], + primaryVariant: 'wavespeed-ai/wan-2.2/animate', + toggles: [], + resolveVariant() { + return 'wavespeed-ai/wan-2.2/animate' + }, + }, + + // 5. InfiniteTalk + { + id: 'infinitetalk', + name: 'InfiniteTalk', + excludeFields: ['audio'], + provider: 'wavespeed-ai', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766575571686877852_Sckigeck.png', + category: 'other', + variantIds: [ + 'wavespeed-ai/infinitetalk', + 'wavespeed-ai/infinitetalk/multi', + 'wavespeed-ai/infinitetalk/video-to-video', + 'wavespeed-ai/infinitetalk-fast', + 'wavespeed-ai/infinitetalk-fast/multi', + 'wavespeed-ai/infinitetalk-fast/video-to-video', + ], + primaryVariant: 'wavespeed-ai/infinitetalk', + toggles: [ + { + key: 'speed', + labelKey: 'smartPlayground.toggleSpeed', + options: [ + { value: 'normal', labelKey: 'smartPlayground.speedNormal' }, + { value: 'fast', labelKey: 'smartPlayground.speedFast' }, + ], + default: 'normal', + }, + ], + resolveVariant(filledFields, toggleValues) { + const hasVideo = hasVideoFilled(filledFields) + const isFast = toggleValues.speed === 'fast' + const hasLeftAudio = filledFields.has('left_audio') + const hasRightAudio = filledFields.has('right_audio') + const hasBothAudios = hasLeftAudio && hasRightAudio + + if (hasVideo) return isFast ? 'wavespeed-ai/infinitetalk-fast/video-to-video' : 'wavespeed-ai/infinitetalk/video-to-video' + if (hasBothAudios) return isFast ? 'wavespeed-ai/infinitetalk-fast/multi' : 'wavespeed-ai/infinitetalk/multi' + return isFast ? 'wavespeed-ai/infinitetalk-fast' : 'wavespeed-ai/infinitetalk' + }, + mapValues(values, resolvedVariantId) { + // For single variant: map left_audio/right_audio → audio + if (resolvedVariantId.includes('/multi') || resolvedVariantId.includes('/video-to-video')) return values + const mapped = { ...values } + if (!mapped.audio) { + if (mapped.left_audio) mapped.audio = mapped.left_audio + else if (mapped.right_audio) mapped.audio = mapped.right_audio + } + delete mapped.left_audio + delete mapped.right_audio + return mapped + }, + }, + + // 6. Kling 2.6 Motion Control + { + id: 'kling-2.6-motion-control', + name: 'Kling 2.6 Motion Control', + provider: 'kwaivgi', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1766519115490596160_Smusqomu.png', + category: 'other', + variantIds: [ + 'kwaivgi/kling-v2.6-pro/motion-control', + 'kwaivgi/kling-v2.6-std/motion-control', + ], + primaryVariant: 'kwaivgi/kling-v2.6-pro/motion-control', + toggles: [ + { + key: 'quality', + labelKey: 'smartPlayground.toggleQuality', + options: [ + { value: 'pro', labelKey: 'smartPlayground.qualityPro' }, + { value: 'std', labelKey: 'smartPlayground.qualityStd' }, + ], + default: 'pro', + }, + ], + resolveVariant(_filledFields, toggleValues) { + return toggleValues.quality === 'std' + ? 'kwaivgi/kling-v2.6-std/motion-control' + : 'kwaivgi/kling-v2.6-pro/motion-control' + }, + }, + + // 7. Nano Banana Pro + { + id: 'nano-banana-pro', + name: 'Nano Banana Pro', + provider: 'google', + poster: 'https://d1q70pf5vjeyhc.wavespeed.ai/media/images/1763649945119973876_WvMIEAxu.jpg', + category: 'image', + variantIds: [ + 'google/nano-banana-pro/text-to-image', + 'google/nano-banana-pro/text-to-image-ultra', + 'google/nano-banana-pro/text-to-image-multi', + 'google/nano-banana-pro/edit', + 'google/nano-banana-pro/edit-ultra', + 'google/nano-banana-pro/edit-multi', + ], + primaryVariant: 'google/nano-banana-pro/text-to-image', + toggles: [ + { + key: 'quality', + labelKey: 'smartPlayground.toggleQuality', + options: [ + { value: 'standard', labelKey: 'smartPlayground.qualityStd' }, + { value: 'ultra', labelKey: 'smartPlayground.qualityUltra' }, + { value: 'multi', labelKey: 'smartPlayground.qualityMulti' }, + ], + default: 'standard', + }, + ], + resolveVariant(filledFields, toggleValues) { + const hasImage = hasImageFilled(filledFields) + const quality = toggleValues.quality || 'standard' + if (hasImage) { + if (quality === 'ultra') return 'google/nano-banana-pro/edit-ultra' + if (quality === 'multi') return 'google/nano-banana-pro/edit-multi' + return 'google/nano-banana-pro/edit' + } + if (quality === 'ultra') return 'google/nano-banana-pro/text-to-image-ultra' + if (quality === 'multi') return 'google/nano-banana-pro/text-to-image-multi' + return 'google/nano-banana-pro/text-to-image' + }, + }, +] + +export function findFamilyById(id: string): SmartFormFamily | undefined { + return SMART_FORM_FAMILIES.find(f => f.id === id) +} diff --git a/src/pages/AudioConverterPage.tsx b/src/pages/AudioConverterPage.tsx index f568eba..4ec659c 100644 --- a/src/pages/AudioConverterPage.tsx +++ b/src/pages/AudioConverterPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { useFFmpegWorker } from '@/hooks/useFFmpegWorker' import { useMultiPhaseProgress } from '@/hooks/useMultiPhaseProgress' diff --git a/src/pages/BackgroundRemoverPage.tsx b/src/pages/BackgroundRemoverPage.tsx index 26ac661..9c22bd5 100644 --- a/src/pages/BackgroundRemoverPage.tsx +++ b/src/pages/BackgroundRemoverPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useBackgroundRemoverWorker } from '@/hooks/useBackgroundRemoverWorker' diff --git a/src/pages/FaceEnhancerPage.tsx b/src/pages/FaceEnhancerPage.tsx index c41970a..4a6add7 100644 --- a/src/pages/FaceEnhancerPage.tsx +++ b/src/pages/FaceEnhancerPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useFaceEnhancerWorker } from '@/hooks/useFaceEnhancerWorker' diff --git a/src/pages/FaceSwapperPage.tsx b/src/pages/FaceSwapperPage.tsx index fd735dd..8c5f894 100644 --- a/src/pages/FaceSwapperPage.tsx +++ b/src/pages/FaceSwapperPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext, useEffect } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useFaceSwapperWorker, type DetectedFace } from '@/hooks/useFaceSwapperWorker' diff --git a/src/pages/FeaturedModelsPage.tsx b/src/pages/FeaturedModelsPage.tsx index be86e1f..e17ec3c 100644 --- a/src/pages/FeaturedModelsPage.tsx +++ b/src/pages/FeaturedModelsPage.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { ArrowLeft, PlayCircle } from 'lucide-react' import { cn } from '@/lib/utils' +import { SMART_FORM_FAMILIES } from '@/lib/smartFormConfig' // Featured model families with all variants const FEATURED_MODEL_FAMILIES = [ @@ -177,7 +178,14 @@ export function FeaturedModelsPage() { "group cursor-pointer rounded-xl border border-border/50 bg-card/60 backdrop-blur-sm transition-all duration-300 hover:shadow-lg hover:scale-[1.02] overflow-hidden", accent.border )} - onClick={() => navigate(`/playground/${family.primaryVariant}`)} + onClick={() => { + const smartFamily = SMART_FORM_FAMILIES.find(sf => sf.primaryVariant === family.primaryVariant) + if (smartFamily) { + navigate(`/featured-models/${smartFamily.id}`) + } else { + navigate(`/playground/${family.primaryVariant}`) + } + }} > {/* Cover image */}
@@ -215,23 +223,6 @@ export function FeaturedModelsPage() { ))}
- {/* Variants list */} -
- {family.variants.map((variant) => ( - - ))} -
- {/* Footer */}
{price !== undefined ? ( diff --git a/src/pages/FreeToolsPage.tsx b/src/pages/FreeToolsPage.tsx index ed0a5ba..6f5983b 100644 --- a/src/pages/FreeToolsPage.tsx +++ b/src/pages/FreeToolsPage.tsx @@ -13,6 +13,11 @@ import backgroundRemoverImg from '../../build/images/BackgroundRemover.jpeg' import imageEraserImg from '../../build/images/ImageEraser.jpeg' import SegmentAnythingImg from '../../build/images/SegmentAnything.png' import freeToolImg from '../../build/images/FreeTool.jpeg' +import videoConverterImg from '../../build/images/VideoConverter.png' +import audioConverterImg from '../../build/images/AudioConverter.png' +import imageConverterImg from '../../build/images/ImageConverter.png' +import mediaTrimmerImg from '../../build/images/MediaTrimmer.png' +import mediaMergerImg from '../../build/images/MediaMerger.png' export function FreeToolsPage() { const { t } = useTranslation() @@ -89,7 +94,7 @@ export function FreeToolsPage() { descriptionKey: 'freeTools.videoConverter.description', route: '/free-tools/video-converter', gradient: 'from-indigo-500/20 via-blue-500/10 to-transparent', - image: freeToolImg + image: videoConverterImg }, { id: 'audio-converter', @@ -98,7 +103,7 @@ export function FreeToolsPage() { descriptionKey: 'freeTools.audioConverter.description', route: '/free-tools/audio-converter', gradient: 'from-teal-500/20 via-cyan-500/10 to-transparent', - image: freeToolImg + image: audioConverterImg }, { id: 'image-converter', @@ -107,7 +112,7 @@ export function FreeToolsPage() { descriptionKey: 'freeTools.imageConverter.description', route: '/free-tools/image-converter', gradient: 'from-amber-500/20 via-yellow-500/10 to-transparent', - image: freeToolImg + image: imageConverterImg }, { id: 'media-trimmer', @@ -116,7 +121,7 @@ export function FreeToolsPage() { descriptionKey: 'freeTools.mediaTrimmer.description', route: '/free-tools/media-trimmer', gradient: 'from-red-500/20 via-orange-500/10 to-transparent', - image: freeToolImg + image: mediaTrimmerImg }, { id: 'media-merger', @@ -125,7 +130,7 @@ export function FreeToolsPage() { descriptionKey: 'freeTools.mediaMerger.description', route: '/free-tools/media-merger', gradient: 'from-purple-500/20 via-fuchsia-500/10 to-transparent', - image: freeToolImg + image: mediaMergerImg } ] diff --git a/src/pages/ImageConverterPage.tsx b/src/pages/ImageConverterPage.tsx index 20964a0..5cd458a 100644 --- a/src/pages/ImageConverterPage.tsx +++ b/src/pages/ImageConverterPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { useFFmpegWorker } from '@/hooks/useFFmpegWorker' import { useMultiPhaseProgress } from '@/hooks/useMultiPhaseProgress' diff --git a/src/pages/ImageEnhancerPage.tsx b/src/pages/ImageEnhancerPage.tsx index 316ad8e..406f63c 100644 --- a/src/pages/ImageEnhancerPage.tsx +++ b/src/pages/ImageEnhancerPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useUpscalerWorker } from '@/hooks/useUpscalerWorker' diff --git a/src/pages/ImageEraserPage.tsx b/src/pages/ImageEraserPage.tsx index a91582b..df51a9c 100644 --- a/src/pages/ImageEraserPage.tsx +++ b/src/pages/ImageEraserPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useImageEraserWorker } from '@/hooks/useImageEraserWorker' diff --git a/src/pages/MediaMergerPage.tsx b/src/pages/MediaMergerPage.tsx index 04c860b..0e12a58 100644 --- a/src/pages/MediaMergerPage.tsx +++ b/src/pages/MediaMergerPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useFFmpegWorker } from '@/hooks/useFFmpegWorker' diff --git a/src/pages/MediaTrimmerPage.tsx b/src/pages/MediaTrimmerPage.tsx index bf7d3de..d752fdd 100644 --- a/src/pages/MediaTrimmerPage.tsx +++ b/src/pages/MediaTrimmerPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { useFFmpegWorker } from '@/hooks/useFFmpegWorker' import { useMultiPhaseProgress } from '@/hooks/useMultiPhaseProgress' diff --git a/src/pages/SegmentAnythingPage.tsx b/src/pages/SegmentAnythingPage.tsx index 0de7464..533a914 100644 --- a/src/pages/SegmentAnythingPage.tsx +++ b/src/pages/SegmentAnythingPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useSegmentAnythingWorker, type MaskResult } from '@/hooks/useSegmentAnythingWorker' diff --git a/src/pages/SmartPlaygroundPage.tsx b/src/pages/SmartPlaygroundPage.tsx new file mode 100644 index 0000000..6ff46ed --- /dev/null +++ b/src/pages/SmartPlaygroundPage.tsx @@ -0,0 +1,749 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { useModelsStore } from '@/stores/modelsStore' +import { useApiKeyStore } from '@/stores/apiKeyStore' +import { apiClient } from '@/api/client' +import { findFamilyById, SMART_FORM_FAMILIES } from '@/lib/smartFormConfig' +import { schemaToFormFields, getDefaultValues, type FormFieldConfig } from '@/lib/schemaToForm' +import type { SchemaProperty } from '@/types/model' +import type { Model } from '@/types/model' +import type { PredictionResult } from '@/types/prediction' +import { FormField } from '@/components/playground/FormField' +import { OutputDisplay } from '@/components/playground/OutputDisplay' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Switch } from '@/components/ui/switch' +import { Slider } from '@/components/ui/slider' +import { Label } from '@/components/ui/label' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { ArrowLeft, Loader2, Play, ChevronDown } from 'lucide-react' +import { cn } from '@/lib/utils' +import { toast } from '@/hooks/useToast' + +function getCategoryAccent(category: 'image' | 'video' | 'other') { + switch (category) { + case 'video': + return 'from-purple-500 to-violet-500' + case 'image': + return 'from-sky-400 to-blue-500' + default: + return 'from-emerald-400 to-teal-500' + } +} + +// Extract schema fields from a model object +function extractModelFields(model: Model): { fields: FormFieldConfig[]; orderProps?: string[] } { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apiSchemas = (model.api_schema as any)?.api_schemas as Array<{ + type: string + request_schema?: { + properties?: Record + required?: string[] + 'x-order-properties'?: string[] + } + }> | undefined + + const requestSchema = apiSchemas?.find(s => s.type === 'model_run')?.request_schema + if (!requestSchema?.properties) { + return { fields: [] } + } + const fields = schemaToFormFields( + requestSchema.properties as Record, + requestSchema.required || [], + requestSchema['x-order-properties'] + ) + return { fields, orderProps: requestSchema['x-order-properties'] } +} + +// Merge fields from multiple variants into one unified field list +function mergeVariantFields( + variants: Model[], + primaryVariant: string +): FormFieldConfig[] { + const fieldMap = new Map() + const primaryModel = variants.find(v => v.model_id === primaryVariant) + + // Get primary variant's order + let primaryOrder: string[] | undefined + if (primaryModel) { + const { fields, orderProps } = extractModelFields(primaryModel) + primaryOrder = orderProps + for (const f of fields) { + fieldMap.set(f.name, { ...f, required: false }) + } + } + + // Merge fields from other variants (add new fields, don't overwrite existing) + for (const variant of variants) { + if (variant.model_id === primaryVariant) continue + const { fields } = extractModelFields(variant) + for (const f of fields) { + if (!fieldMap.has(f.name)) { + fieldMap.set(f.name, { ...f, required: false }) + } + } + } + + const allFields = Array.from(fieldMap.values()) + + // Sort by primary variant's order, extras at end + if (primaryOrder && primaryOrder.length > 0) { + allFields.sort((a, b) => { + const idxA = primaryOrder!.indexOf(a.name) + const idxB = primaryOrder!.indexOf(b.name) + const orderA = idxA === -1 ? Infinity : idxA + const orderB = idxB === -1 ? Infinity : idxB + if (orderA !== orderB) return orderA - orderB + // For unordered fields, put prompt-like fields first + if (a.name === 'prompt') return -1 + if (b.name === 'prompt') return 1 + return a.name.localeCompare(b.name) + }) + } + + return allFields +} + +// Get which field names a variant accepts +function getVariantFieldNames(model: Model): Set { + const { fields } = extractModelFields(model) + return new Set(fields.map(f => f.name)) +} + +// Media field names for tracking last uploaded type +const IMAGE_FIELD_NAMES = ['image', 'images', 'image_url', 'image_urls', 'input_image'] +const VIDEO_FIELD_NAMES = ['video', 'videos', 'video_url', 'video_urls', 'input_video'] + +export function SmartPlaygroundPage() { + const { t } = useTranslation() + const { familyId } = useParams<{ familyId: string }>() + const navigate = useNavigate() + const { models, fetchModels } = useModelsStore() + const { isValidated } = useApiKeyStore() + + // Local state + const [toggleValues, setToggleValues] = useState>({}) + const [formValues, setFormValues] = useState>({}) + const [isRunning, setIsRunning] = useState(false) + const [error, setError] = useState(null) + const [outputs, setOutputs] = useState<(string | Record)[]>([]) + const [prediction, setPrediction] = useState(null) + const [mobileView, setMobileView] = useState<'input' | 'output'>('input') + const [isUploading, setIsUploading] = useState(false) + const [calculatedPrice, setCalculatedPrice] = useState(null) + const [batchEnabled, setBatchEnabled] = useState(false) + const [batchCount, setBatchCount] = useState(2) + const [batchRandomizeSeed, setBatchRandomizeSeed] = useState(true) + const [batchProgress, setBatchProgress] = useState<{ current: number; total: number } | null>(null) + const [lastMediaType, setLastMediaType] = useState<'image' | 'video' | null>(null) + const pricingTimeoutRef = useRef | null>(null) + const defaultsInitializedRef = useRef(null) + + // Find family config (SMART_FORM_FAMILIES in deps ensures HMR updates propagate) + // eslint-disable-next-line react-hooks/exhaustive-deps + const family = useMemo(() => findFamilyById(familyId || ''), [familyId, SMART_FORM_FAMILIES]) + + // Ensure models are fetched + useEffect(() => { + if (isValidated) { + fetchModels() + } + }, [isValidated, fetchModels]) + + // Initialize toggle defaults + useEffect(() => { + if (!family) return + const defaults: Record = {} + for (const toggle of family.toggles) { + defaults[toggle.key] = toggle.default + } + setToggleValues(defaults) + }, [family]) + + // Get variant models + const variantModels = useMemo(() => { + if (!family) return [] + return family.variantIds + .map(id => models.find(m => m.model_id === id)) + .filter((m): m is Model => !!m) + }, [family, models]) + + // Merged fields + const mergedFields = useMemo(() => { + if (variantModels.length === 0 || !family) return [] + const fields = mergeVariantFields(variantModels, family.primaryVariant) + if (family.excludeFields?.length) { + const excluded = new Set(family.excludeFields) + return fields.filter(f => !excluded.has(f.name)) + } + return fields + }, [variantModels, family]) + + // Initialize form defaults + useEffect(() => { + if (mergedFields.length === 0 || !family) return + if (defaultsInitializedRef.current === family.id) return + defaultsInitializedRef.current = family.id + const defaults = getDefaultValues(mergedFields) + setFormValues(defaults) + }, [mergedFields, family]) + + // Compute filled fields (for variant resolution) + // When both image and video are filled, only keep the last uploaded type + const filledFields = useMemo(() => { + const filled = new Set() + for (const [key, value] of Object.entries(formValues)) { + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value) && value.length === 0) continue + filled.add(key) + } + + // Conflict resolution: if both image and video fields are filled, remove the earlier one + const hasImage = IMAGE_FIELD_NAMES.some(n => filled.has(n)) + const hasVideo = VIDEO_FIELD_NAMES.some(n => filled.has(n)) + if (hasImage && hasVideo && lastMediaType) { + const toRemove = lastMediaType === 'video' ? IMAGE_FIELD_NAMES : VIDEO_FIELD_NAMES + for (const name of toRemove) filled.delete(name) + } + + // Debug: log filled fields for variant resolution + if (filled.size > 0) { + console.log('[SmartPlayground] filledFields:', [...filled]) + } + return filled + }, [formValues, lastMediaType]) + + // Resolve variant + const resolvedVariantId = useMemo(() => { + if (!family) return '' + const result = family.resolveVariant(filledFields, toggleValues) + console.log('[SmartPlayground] resolvedVariant:', result, 'filledFields:', [...filledFields]) + return result + }, [family, filledFields, toggleValues]) + + const resolvedModel = useMemo(() => { + return models.find(m => m.model_id === resolvedVariantId) + }, [models, resolvedVariantId]) + + // Fields that the resolved variant accepts (for dynamic show/hide) + const resolvedVariantFieldNames = useMemo(() => { + if (!resolvedModel) return new Set() + return getVariantFieldNames(resolvedModel) + }, [resolvedModel]) + + // Use resolved variant's own field configs (with its specific options/ranges), + // plus always show trigger fields (file/loras) from merged set for variant switching + const visibleFields = useMemo(() => { + if (!resolvedModel || resolvedVariantFieldNames.size === 0) return mergedFields + const triggerTypes = new Set(['file', 'file-array', 'loras']) + + // Get actual field configs from the resolved variant + const { fields: resolvedFields } = extractModelFields(resolvedModel) + const resolvedFieldMap = new Map(resolvedFields.map(f => [f.name, { ...f, required: false }])) + + // Build visible fields: resolved variant's fields (with its own config) + trigger fields from merged + const result: FormFieldConfig[] = [] + const added = new Set() + + // First add fields in merged order (preserves nice ordering) + for (const mf of mergedFields) { + if (resolvedFieldMap.has(mf.name)) { + // Use resolved variant's config (has correct select options, ranges, etc.) + result.push(resolvedFieldMap.get(mf.name)!) + added.add(mf.name) + } else if (triggerTypes.has(mf.type)) { + // Trigger field not in resolved variant — keep from merged for switching + result.push(mf) + added.add(mf.name) + } + } + + return result + }, [mergedFields, resolvedModel, resolvedVariantFieldNames]) + + // Auto-disable batch when resolved variant has native max_images (e.g. sequential) + useEffect(() => { + if (resolvedVariantFieldNames.has('max_images')) { + setBatchEnabled(false) + } + }, [resolvedVariantFieldNames]) + + // Clean up invalid select values when variant changes (e.g. 480p not available in fast) + useEffect(() => { + if (visibleFields.length === 0) return + setFormValues(prev => { + const next = { ...prev } + let changed = false + for (const field of visibleFields) { + if (field.type === 'select' && field.options && prev[field.name] !== undefined) { + const currentVal = prev[field.name] + if (!field.options.includes(currentVal as string | number)) { + // Current value not in new options — reset to default or first option + next[field.name] = field.default ?? field.options[0] + changed = true + } + } + } + return changed ? next : prev + }) + }, [visibleFields]) + + // Dynamic pricing with debounce + useEffect(() => { + if (!resolvedVariantId || !resolvedModel) { + setCalculatedPrice(null) + return + } + + if (pricingTimeoutRef.current) { + clearTimeout(pricingTimeoutRef.current) + } + + pricingTimeoutRef.current = setTimeout(async () => { + try { + const variantFieldNames = getVariantFieldNames(resolvedModel) + const filteredValues: Record = {} + for (const [key, value] of Object.entries(formValues)) { + if (!variantFieldNames.has(key)) continue + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value) && value.length === 0) continue + filteredValues[key] = value + } + const price = await apiClient.calculatePricing(resolvedVariantId, filteredValues) + setCalculatedPrice(price) + } catch { + // Pricing calculation failed silently + } + }, 500) + + return () => { + if (pricingTimeoutRef.current) { + clearTimeout(pricingTimeoutRef.current) + } + } + }, [resolvedVariantId, resolvedModel, formValues]) + + // Handle form value change + const handleFieldChange = useCallback((key: string, value: unknown) => { + setFormValues(prev => ({ ...prev, [key]: value })) + + // Track last uploaded media type + const isFilled = value !== undefined && value !== null && value !== '' && + !(Array.isArray(value) && value.length === 0) + if (isFilled) { + if (IMAGE_FIELD_NAMES.includes(key)) setLastMediaType('image') + else if (VIDEO_FIELD_NAMES.includes(key)) setLastMediaType('video') + } else { + // Cleared — reset if it was this type + if (IMAGE_FIELD_NAMES.includes(key) && lastMediaType === 'image') setLastMediaType(null) + else if (VIDEO_FIELD_NAMES.includes(key) && lastMediaType === 'video') setLastMediaType(null) + } + }, [lastMediaType]) + + // Handle toggle change + const handleToggleChange = useCallback((key: string, value: string) => { + setToggleValues(prev => ({ ...prev, [key]: value })) + }, []) + + // Build cleaned values for the resolved variant + const buildCleanedValues = useCallback(() => { + if (!resolvedModel) return {} + // Apply family-level value mapping first (e.g. left_audio → audio for InfiniteTalk) + const mappedValues = family?.mapValues + ? family.mapValues({ ...formValues }, resolvedVariantId) + : formValues + const variantFieldNames = getVariantFieldNames(resolvedModel) + const cleanedValues: Record = {} + for (const [key, value] of Object.entries(mappedValues)) { + if (!variantFieldNames.has(key)) continue + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value) && value.length === 0) continue + cleanedValues[key] = value + } + return cleanedValues + }, [resolvedModel, formValues, family, resolvedVariantId]) + + // Run prediction (single or batch) + const handleRun = useCallback(async () => { + if (!resolvedVariantId || !resolvedModel || isRunning) return + + setIsRunning(true) + setError(null) + setOutputs([]) + setPrediction(null) + setMobileView('output') + setBatchProgress(null) + + try { + const cleanedValues = buildCleanedValues() + const runCount = batchEnabled ? batchCount : 1 + + const allOutputs: (string | Record)[] = [] + let lastPrediction: PredictionResult | null = null + + for (let i = 0; i < runCount; i++) { + if (runCount > 1) { + setBatchProgress({ current: i + 1, total: runCount }) + } + + const runValues = { ...cleanedValues } + // Randomize seed for batch runs (skip first run to keep original seed) + if (batchEnabled && batchRandomizeSeed && i > 0 && 'seed' in runValues) { + runValues.seed = Math.floor(Math.random() * 65536) + } + + const result = await apiClient.run(resolvedVariantId, runValues) + lastPrediction = result + if (result.outputs) { + allOutputs.push(...result.outputs) + } + // Update outputs progressively + setOutputs([...allOutputs]) + setPrediction(result) + } + + setPrediction(lastPrediction) + setOutputs(allOutputs) + } catch (err) { + const message = err instanceof Error ? err.message : 'Prediction failed' + setError(message) + toast({ + title: t('common.error'), + description: message, + variant: 'destructive', + }) + } finally { + setIsRunning(false) + setBatchProgress(null) + } + }, [resolvedVariantId, resolvedModel, formValues, isRunning, batchEnabled, batchCount, batchRandomizeSeed, buildCleanedValues, t]) + + // Not found + if (!family) { + return ( +
+
+

Family not found

+ +
+
+ ) + } + + // Loading state + if (variantModels.length === 0 && models.length === 0) { + return ( +
+ +
+ ) + } + + const shortVariantId = resolvedVariantId.split('/').slice(-1)[0] || resolvedVariantId + + return ( +
+ {/* Header */} +
+
+ + {family.name} +
+

{family.name}

+
+ + {shortVariantId} + + {calculatedPrice !== null && ( + ${calculatedPrice.toFixed(4)} + )} +
+
+
+ + {/* Toggles */} + {family.toggles.length > 0 && ( +
+ {family.toggles.map(toggle => ( +
+ {t(toggle.labelKey)}: +
+ {toggle.options.map(option => ( + + ))} +
+
+ ))} +
+ )} +
+ + {/* Mobile Tab Switcher */} +
+ + +
+ + {/* Main Content */} +
+ {/* Left Panel: Form (desktop always visible, mobile conditional) */} +
+ {/* Variant indicator */} +
+
+
+ {t('smartPlayground.willCall')}: +
+

{resolvedVariantId}

+
+ + {/* Form Fields */} + +
+ {visibleFields.map(field => { + if (field.hidden) { + return ( + handleFieldChange(field.name, value)} + disabled={isRunning} + formValues={formValues} + onUploadingChange={setIsUploading} + /> + ) + } + return ( + handleFieldChange(field.name, value)} + disabled={isRunning} + formValues={formValues} + onUploadingChange={setIsUploading} + /> + ) + })} +
+
+ + {/* Run Button with Batch */} +
+
+ + + + + + +
+
{t('playground.batch.settings')}
+ {batchEnabled && ( + <> +
+
+ + {batchCount} +
+ setBatchCount(v[0])} + min={2} + max={16} + step={1} + className="w-full" + /> +
+
+ + +
+ + )} +
+ + +
+
+
+
+
+
+
+ + {/* Right Panel: Output */} +
+
+ 1} + /> +
+
+
+
+ ) +} + +// Hidden field toggle component (mirrors DynamicForm pattern) +function HiddenFieldToggle({ + field, + value, + onChange, + disabled, + formValues, + onUploadingChange, +}: { + field: FormFieldConfig + value: unknown + onChange: (value: unknown) => void + disabled: boolean + formValues: Record + onUploadingChange?: (isUploading: boolean) => void +}) { + const [isEnabled, setIsEnabled] = useState(false) + + const handleToggle = () => { + if (isEnabled) { + onChange(undefined) + } + setIsEnabled(!isEnabled) + } + + return ( +
+ + {field.description && !isEnabled && ( +

{field.description}

+ )} + {isEnabled && ( +
+ +
+ )} +
+ ) +} diff --git a/src/pages/VideoConverterPage.tsx b/src/pages/VideoConverterPage.tsx index 4a5fb64..4fdcc7b 100644 --- a/src/pages/VideoConverterPage.tsx +++ b/src/pages/VideoConverterPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { useFFmpegWorker } from '@/hooks/useFFmpegWorker' import { useMultiPhaseProgress } from '@/hooks/useMultiPhaseProgress' diff --git a/src/pages/VideoEnhancerPage.tsx b/src/pages/VideoEnhancerPage.tsx index 76e8f51..1cc235b 100644 --- a/src/pages/VideoEnhancerPage.tsx +++ b/src/pages/VideoEnhancerPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback, useEffect, useContext } from 'react' import { useNavigate, useLocation } from 'react-router-dom' -import { PageResetContext } from '@/components/layout/Layout' +import { PageResetContext } from '@/components/layout/PageResetContext' import { useTranslation } from 'react-i18next' import { generateFreeToolFilename } from '@/stores/assetsStore' import { useUpscalerWorker } from '@/hooks/useUpscalerWorker' @@ -419,7 +419,7 @@ export function VideoEnhancerPage() { return (
)} - {/* Header */} -
+ {/* Header - hidden on mobile (MobileHeader already shows title) */} +
+ {/* Mobile back button */} + {/* Upload area */} {!videoUrl && ( @@ -497,9 +506,9 @@ export function VideoEnhancerPage() { {/* Video preview area */} {videoUrl && ( -
+
{/* Controls */} -
+