From 7886db53b0b28804e0ea068eeaf7331ea5e6f996 Mon Sep 17 00:00:00 2001 From: Meng Jianwen Date: Fri, 13 Mar 2026 17:56:15 +0800 Subject: [PATCH] settings: persist generation defaults and player mute state Extend AppSettings to remember user's preferred generation parameters (model, duration, resolution, fps, aspect ratio, camera motion) and video player mute state across app restarts. Previously these settings were hardcoded defaults that reset on every launch, causing users to repeatedly adjust their preferences. Backend: - Add 7 new fields to AppSettings and SettingsResponse - Add validators for duration and fps clamping Frontend: - Initialize Playground/GenSpace settings from appSettings - Sync user changes back to backend via updateSettings - VideoPlayer now reads/writes playerMuted from appSettings Closes #29 Co-Authored-By: Claude Opus 4.6 --- backend/state/app_settings.py | 32 +++++++++++ frontend/components/VideoPlayer.tsx | 10 +++- frontend/contexts/AppSettingsContext.tsx | 33 +++++++++++ frontend/views/GenSpace.tsx | 49 ++++++++++++++--- frontend/views/Playground.tsx | 70 +++++++++++++++++++----- 5 files changed, 168 insertions(+), 26 deletions(-) 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 && (