@@ -215,23 +223,6 @@ export function FeaturedModelsPage() {
))}
- {/* Variants list */}
-
{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}
+
+
+ {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 */}
+
+
+
+ )
+}
+
+// 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 */}
-
+