diff --git a/backend/state/app_settings.py b/backend/state/app_settings.py index 75b51ec2..14d9d1cb 100644 --- a/backend/state/app_settings.py +++ b/backend/state/app_settings.py @@ -76,6 +76,17 @@ class AppSettings(SettingsBaseModel): locked_seed: int = 42 models_dir: str = "" + # Generation defaults (persisted across sessions) + default_model: str = "fast" + default_duration: int = 5 + default_video_resolution: str = "540p" + default_fps: int = 24 + default_aspect_ratio: str = "16:9" + default_camera_motion: str = "none" + + # Player preferences + player_muted: bool = False + @field_validator("prompt_cache_size", mode="before") @classmethod def _clamp_prompt_cache_size(cls, value: Any) -> int: @@ -86,6 +97,16 @@ def _clamp_prompt_cache_size(cls, value: Any) -> int: def _clamp_locked_seed(cls, value: Any) -> int: return _clamp_int(value, minimum=0, maximum=2_147_483_647, default=42) + @field_validator("default_duration", mode="before") + @classmethod + def _clamp_default_duration(cls, value: Any) -> int: + return _clamp_int(value, minimum=1, maximum=20, default=5) + + @field_validator("default_fps", mode="before") + @classmethod + def _clamp_default_fps(cls, value: Any) -> int: + return _clamp_int(value, minimum=1, maximum=60, default=24) + SettingsModelT = TypeVar("SettingsModelT", bound=SettingsBaseModel) _PARTIAL_MODEL_CACHE: dict[type[SettingsBaseModel], type[SettingsPatchModel]] = {} @@ -148,6 +169,17 @@ class SettingsResponse(SettingsBaseModel): locked_seed: int = 42 models_dir: str = "" + # Generation defaults + default_model: str = "fast" + default_duration: int = 5 + default_video_resolution: str = "540p" + default_fps: int = 24 + default_aspect_ratio: str = "16:9" + default_camera_motion: str = "none" + + # Player preferences + player_muted: bool = False + def to_settings_response(settings: AppSettings) -> SettingsResponse: data = settings.model_dump(by_alias=False) diff --git a/frontend/components/VideoPlayer.tsx b/frontend/components/VideoPlayer.tsx index 2c3e1cb6..fa0d030c 100644 --- a/frontend/components/VideoPlayer.tsx +++ b/frontend/components/VideoPlayer.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react' import { Play, Pause, Download, RefreshCw, RotateCcw, Volume2, VolumeX, Maximize2 } from 'lucide-react' import { Button } from './ui/button' import { logger } from '../lib/logger' +import { useAppSettings } from '../contexts/AppSettingsContext' interface VideoPlayerProps { videoUrl: string | null @@ -19,6 +20,7 @@ function formatTime(seconds: number): string { } export function VideoPlayer({ videoUrl, videoPath, videoResolution, isGenerating, progress, statusMessage }: VideoPlayerProps) { + const { settings: appSettings, updateSettings: updateAppSettings } = useAppSettings() const videoRef = useRef(null) const progressRef = useRef(null) const [isPlaying, setIsPlaying] = useState(false) @@ -26,7 +28,7 @@ export function VideoPlayer({ videoUrl, videoPath, videoResolution, isGenerating const [duration, setDuration] = useState(0) const [isDragging, setIsDragging] = useState(false) const [isLooping, setIsLooping] = useState(true) - const [isMuted, setIsMuted] = useState(false) + const [isMuted, setIsMuted] = useState(appSettings.playerMuted) const [isHovering, setIsHovering] = useState(false) const [hasBeenUpscaled, setHasBeenUpscaled] = useState(false) const [_currentResolution, setCurrentResolution] = useState(null) @@ -203,8 +205,10 @@ export function VideoPlayer({ videoUrl, videoPath, videoResolution, isGenerating const toggleMute = () => { if (videoRef.current) { - videoRef.current.muted = !isMuted - setIsMuted(!isMuted) + const newMuted = !isMuted + videoRef.current.muted = newMuted + setIsMuted(newMuted) + updateAppSettings({ playerMuted: newMuted }) } } diff --git a/frontend/contexts/AppSettingsContext.tsx b/frontend/contexts/AppSettingsContext.tsx index 5b7d4d38..a310b7cc 100644 --- a/frontend/contexts/AppSettingsContext.tsx +++ b/frontend/contexts/AppSettingsContext.tsx @@ -26,6 +26,17 @@ export interface AppSettings { seedLocked: boolean lockedSeed: number modelsDir: string + + // Generation defaults (persisted across sessions) + defaultModel: string + defaultDuration: number + defaultVideoResolution: string + defaultFps: number + defaultAspectRatio: string + defaultCameraMotion: string + + // Player preferences + playerMuted: boolean } export const DEFAULT_APP_SETTINGS: AppSettings = { @@ -44,6 +55,17 @@ export const DEFAULT_APP_SETTINGS: AppSettings = { seedLocked: false, lockedSeed: 42, modelsDir: '', + + // Generation defaults + defaultModel: 'fast', + defaultDuration: 5, + defaultVideoResolution: '540p', + defaultFps: 24, + defaultAspectRatio: '16:9', + defaultCameraMotion: 'none', + + // Player preferences + playerMuted: false, } type BackendProcessStatus = 'alive' | 'restarting' | 'dead' @@ -92,6 +114,17 @@ function normalizeAppSettings(data: Partial): AppSettings { seedLocked: data.seedLocked ?? DEFAULT_APP_SETTINGS.seedLocked, lockedSeed: data.lockedSeed ?? DEFAULT_APP_SETTINGS.lockedSeed, modelsDir: data.modelsDir ?? DEFAULT_APP_SETTINGS.modelsDir, + + // Generation defaults + defaultModel: data.defaultModel ?? DEFAULT_APP_SETTINGS.defaultModel, + defaultDuration: data.defaultDuration ?? DEFAULT_APP_SETTINGS.defaultDuration, + defaultVideoResolution: data.defaultVideoResolution ?? DEFAULT_APP_SETTINGS.defaultVideoResolution, + defaultFps: data.defaultFps ?? DEFAULT_APP_SETTINGS.defaultFps, + defaultAspectRatio: data.defaultAspectRatio ?? DEFAULT_APP_SETTINGS.defaultAspectRatio, + defaultCameraMotion: data.defaultCameraMotion ?? DEFAULT_APP_SETTINGS.defaultCameraMotion, + + // Player preferences + playerMuted: data.playerMuted ?? DEFAULT_APP_SETTINGS.playerMuted, } } diff --git a/frontend/views/GenSpace.tsx b/frontend/views/GenSpace.tsx index 8d36dd1c..76bfc49e 100644 --- a/frontend/views/GenSpace.tsx +++ b/frontend/views/GenSpace.tsx @@ -836,12 +836,8 @@ const gallerySizeClasses: Record = { large: 'grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3', } -const DEFAULT_VIDEO_SETTINGS = { - model: 'fast', - duration: 5, - videoResolution: '540p', - fps: 24, - aspectRatio: '16:9', +// Fallback defaults for fields not persisted in appSettings +const IMAGE_SETTINGS_DEFAULTS = { imageResolution: '1080p', variations: 1, audio: true, @@ -867,7 +863,7 @@ export function GenSpace() { setGenSpaceIcLoraSource, setPendingIcLoraUpdate, } = useProjects() - const { shouldVideoGenerateWithLtxApi, forceApiGenerations, settings: appSettings } = useAppSettings() + const { shouldVideoGenerateWithLtxApi, forceApiGenerations, settings: appSettings, updateSettings: updateAppSettings, isLoaded: appSettingsLoaded } = useAppSettings() const [mode, setMode] = useState<'image' | 'video' | 'retake' | 'ic-lora'>('video') const [prompt, setPrompt] = useState('') const [inputImage, setInputImage] = useState(null) @@ -897,7 +893,42 @@ export function GenSpace() { conditioningStrength: number } } | null>(null) - const [settings, setSettings] = useState(() => ({ ...DEFAULT_VIDEO_SETTINGS })) + + // Initialize settings from persisted appSettings + const [settings, setSettings] = useState(() => ({ + model: appSettings.defaultModel, + duration: appSettings.defaultDuration, + videoResolution: appSettings.defaultVideoResolution, + fps: appSettings.defaultFps, + aspectRatio: appSettings.defaultAspectRatio, + ...IMAGE_SETTINGS_DEFAULTS, + })) + + // Sync settings from appSettings when loaded + useEffect(() => { + if (!appSettingsLoaded) return + setSettings(prev => ({ + ...prev, + model: appSettings.defaultModel, + duration: appSettings.defaultDuration, + videoResolution: appSettings.defaultVideoResolution, + fps: appSettings.defaultFps, + aspectRatio: appSettings.defaultAspectRatio, + })) + }, [appSettingsLoaded]) // eslint-disable-line react-hooks/exhaustive-deps + + // Persist generation defaults when user changes them + const handleSettingsChange = useCallback((newSettings: typeof settings) => { + setSettings(newSettings) + // Sync persisted fields to appSettings + updateAppSettings({ + defaultModel: newSettings.model, + defaultDuration: newSettings.duration, + defaultVideoResolution: newSettings.videoResolution, + defaultFps: newSettings.fps, + defaultAspectRatio: newSettings.aspectRatio, + }) + }, [updateAppSettings]) const applyForcedVideoSettings = useCallback( (next: { model: string; duration: number; videoResolution: string; fps: number; audio: boolean; aspectRatio: string; imageResolution: string; variations: number }) => { if (!shouldVideoGenerateWithLtxApi || mode !== 'video') return next @@ -1661,7 +1692,7 @@ export function GenSpace() { inputAudio={inputAudio} onInputAudioChange={setInputAudio} settings={settings} - onSettingsChange={(nextSettings) => setSettings(applyForcedVideoSettings(nextSettings))} + onSettingsChange={(nextSettings) => handleSettingsChange(applyForcedVideoSettings(nextSettings))} shouldVideoGenerateWithLtxApi={shouldVideoGenerateWithLtxApi} icLoraCondType={icLoraCondType} onIcLoraCondTypeChange={setIcLoraCondType} diff --git a/frontend/views/Playground.tsx b/frontend/views/Playground.tsx index 9165ea8f..9f69bb69 100644 --- a/frontend/views/Playground.tsx +++ b/frontend/views/Playground.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback } from 'react' import { Sparkles, Trash2, Square, ImageIcon, ArrowLeft, Scissors } from 'lucide-react' import { logger } from '../lib/logger' import { ImageUploader } from '../components/ImageUploader' @@ -22,15 +22,8 @@ import { sanitizeForcedApiVideoSettings } from '../lib/api-video-options' import { RetakePanel } from '../components/RetakePanel' import { ICLoraPanel, CONDITIONING_TYPES, type ICLoraConditioningType } from '../components/ICLoraPanel' -const DEFAULT_SETTINGS: GenerationSettings = { - model: 'fast', - duration: 5, - videoResolution: '540p', - fps: 24, - audio: true, - cameraMotion: 'none', - aspectRatio: '16:9', - // Image settings +// Fallback defaults for fields not persisted in appSettings +const IMAGE_SETTINGS_DEFAULTS = { imageResolution: '1080p', imageAspectRatio: '16:9', imageSteps: 4, @@ -38,12 +31,51 @@ const DEFAULT_SETTINGS: GenerationSettings = { export function Playground() { const { goHome } = useProjects() - const { forceApiGenerations, shouldVideoGenerateWithLtxApi } = useAppSettings() + const { settings: appSettings, updateSettings: updateAppSettings, isLoaded: appSettingsLoaded, forceApiGenerations, shouldVideoGenerateWithLtxApi } = useAppSettings() const [mode, setMode] = useState('text-to-video') const [prompt, setPrompt] = useState('') const [selectedImage, setSelectedImage] = useState(null) const [selectedAudio, setSelectedAudio] = useState(null) - const [settings, setSettings] = useState(() => ({ ...DEFAULT_SETTINGS })) + + // Initialize settings from persisted appSettings + const [settings, setSettings] = useState(() => ({ + model: appSettings.defaultModel as 'fast' | 'pro', + duration: appSettings.defaultDuration, + videoResolution: appSettings.defaultVideoResolution as '540p' | '720p' | '1080p', + fps: appSettings.defaultFps, + audio: true, + cameraMotion: appSettings.defaultCameraMotion, + aspectRatio: appSettings.defaultAspectRatio as '16:9' | '9:16' | '1:1', + ...IMAGE_SETTINGS_DEFAULTS, + })) + + // Sync settings from appSettings when loaded + useEffect(() => { + if (!appSettingsLoaded) return + setSettings(prev => ({ + ...prev, + model: appSettings.defaultModel as 'fast' | 'pro', + duration: appSettings.defaultDuration, + videoResolution: appSettings.defaultVideoResolution as '540p' | '720p' | '1080p', + fps: appSettings.defaultFps, + cameraMotion: appSettings.defaultCameraMotion, + aspectRatio: appSettings.defaultAspectRatio as '16:9' | '9:16' | '1:1', + })) + }, [appSettingsLoaded]) // eslint-disable-line react-hooks/exhaustive-deps + + // Persist generation defaults when user changes them + const handleSettingsChange = useCallback((newSettings: GenerationSettings) => { + setSettings(newSettings) + // Sync persisted fields to appSettings + updateAppSettings({ + defaultModel: newSettings.model, + defaultDuration: newSettings.duration, + defaultVideoResolution: newSettings.videoResolution, + defaultFps: newSettings.fps, + defaultAspectRatio: newSettings.aspectRatio, + defaultCameraMotion: newSettings.cameraMotion, + }) + }, [updateAppSettings]) const { status, processStatus } = useBackend() @@ -187,7 +219,17 @@ export function Playground() { setPrompt('') setSelectedImage(null) setSelectedAudio(null) - const baseDefaults = { ...DEFAULT_SETTINGS } + // Reset to user's persisted defaults (not hardcoded defaults) + const baseDefaults: GenerationSettings = { + model: appSettings.defaultModel as 'fast' | 'pro', + duration: appSettings.defaultDuration, + videoResolution: appSettings.defaultVideoResolution as '540p' | '720p' | '1080p', + fps: appSettings.defaultFps, + audio: true, + cameraMotion: appSettings.defaultCameraMotion, + aspectRatio: appSettings.defaultAspectRatio as '16:9' | '9:16' | '1:1', + ...IMAGE_SETTINGS_DEFAULTS, + } const shouldSanitizeVideoSettings = shouldVideoGenerateWithLtxApi && mode !== 'text-to-image' setSettings(shouldSanitizeVideoSettings ? sanitizeForcedApiVideoSettings(baseDefaults) : baseDefaults) if (mode !== 'text-to-image') setMode('text-to-video') @@ -358,7 +400,7 @@ export function Playground() { {!isRetakeMode && !isIcLoraMode && (