1- import { useEffect , useMemo , useRef , useState } from "react" ;
1+ import { useEffect , useMemo , useReducer , useRef , useState } from "react" ;
22import { AlertCircle , Loader2 , PlayCircle , RotateCcw , Save , Sparkles , Undo2 , Wand2 } from "lucide-react" ;
33import type { ApiStatus , BackendOptionsData , CreateVideoPayload } from "../api" ;
44import { createVideo , generateScript , generateTerms , getOptions , getTask } from "../api" ;
@@ -25,7 +25,7 @@ import {
2525import {
2626 ensureSelectedOption ,
2727 ensureSelectedVoiceGroup ,
28- getFirstVoice ,
28+ getEffectiveStudioOptionSelections ,
2929 normalizeStudioOptions ,
3030} from "../studioOptions" ;
3131import {
@@ -44,6 +44,32 @@ type StudioPageProps = {
4444 onTaskChange : ( taskId : string , update : TaskUpdate & Pick < SubmittedTask , "subject" > ) => void ;
4545} ;
4646
47+ type OptionsFetchStatus = "idle" | "loading" | "loaded" | "error" ;
48+
49+ type OptionsFetchState = {
50+ status : OptionsFetchStatus ;
51+ data : BackendOptionsData | null ;
52+ error : string ;
53+ } ;
54+
55+ type OptionsFetchAction =
56+ | { type : "loading" }
57+ | { type : "loaded" ; data : BackendOptionsData }
58+ | { type : "error" ; error : string } ;
59+
60+ const INITIAL_OPTIONS_FETCH_STATE : OptionsFetchState = { status : "idle" , data : null , error : "" } ;
61+
62+ function optionsFetchReducer ( _state : OptionsFetchState , action : OptionsFetchAction ) : OptionsFetchState {
63+ switch ( action . type ) {
64+ case "loading" :
65+ return { status : "loading" , data : null , error : "" } ;
66+ case "loaded" :
67+ return { status : "loaded" , data : action . data , error : "" } ;
68+ case "error" :
69+ return { status : "error" , data : null , error : action . error } ;
70+ }
71+ }
72+
4773export function StudioPage ( { status, onTaskChange } : StudioPageProps ) {
4874 const [ initialStudioDefaults ] = useState < StudioDefaultsLoadResult > ( ( ) => getInitialStudioDefaults ( ) ) ;
4975 const [ subject , setSubject ] = useState ( "" ) ;
@@ -58,28 +84,34 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
5884 const [ subtitleEnabled , setSubtitleEnabled ] = useState ( initialStudioDefaults . settings . subtitleEnabled ) ;
5985 const [ studioMessage , setStudioMessage ] = useState ( initialStudioDefaults . message ?? "" ) ;
6086 const [ studioError , setStudioError ] = useState ( "" ) ;
61- const [ optionsData , setOptionsData ] = useState < BackendOptionsData | null > ( null ) ;
62- const [ optionsError , setOptionsError ] = useState ( "" ) ;
87+ const [ optionsFetchState , dispatchOptionsFetch ] = useReducer ( optionsFetchReducer , INITIAL_OPTIONS_FETCH_STATE ) ;
6388 const [ isGeneratingScript , setIsGeneratingScript ] = useState ( false ) ;
6489 const [ isGeneratingTerms , setIsGeneratingTerms ] = useState ( false ) ;
6590 const [ isSubmittingVideo , setIsSubmittingVideo ] = useState ( false ) ;
6691 const [ activeTask , setActiveTask ] = useState < SubmittedTask | null > ( null ) ;
6792 const [ inspectorSelection , setInspectorSelection ] = useState < OutputInspectSelection | null > ( null ) ;
6893 const pollControllerRef = useRef < AbortController | null > ( null ) ;
6994 const pollGenerationRef = useRef ( 0 ) ;
70- const selectedOptionsRef = useRef ( { aspect, videoSource, voiceName } ) ;
7195
96+ const backendReady = status . state === "online" ;
97+ const optionsFetchStatus = backendReady ? optionsFetchState . status : "idle" ;
98+ const optionsLoaded = optionsFetchStatus === "loaded" ;
99+ const optionsError = optionsFetchStatus === "error" ? optionsFetchState . error : "" ;
100+ const optionsData = optionsLoaded ? optionsFetchState . data : null ;
72101 const studioOptions = useMemo ( ( ) => normalizeStudioOptions ( optionsData ) , [ optionsData ] ) ;
102+ const effectiveOptions = getEffectiveStudioOptionSelections ( studioOptions , { videoAspect : aspect , videoSource, voiceName } ) ;
103+ const selectedVideoAspect = optionsLoaded || optionsError ? effectiveOptions . videoAspect : aspect ;
104+ const selectedVideoSource = optionsLoaded || optionsError ? effectiveOptions . videoSource : videoSource ;
105+ const selectedVoiceName = optionsLoaded ? effectiveOptions . voiceName : voiceName ;
73106 const languageOptions = useMemo (
74107 ( ) => ensureSelectedOption ( studioOptions . languages , language , "Current language" ) ,
75108 [ language , studioOptions . languages ] ,
76109 ) ;
77110 const voiceGroups = useMemo (
78- ( ) => ensureSelectedVoiceGroup ( studioOptions . voiceGroups , voiceName ) ,
79- [ studioOptions . voiceGroups , voiceName ] ,
111+ ( ) => ensureSelectedVoiceGroup ( studioOptions . voiceGroups , selectedVoiceName ) ,
112+ [ selectedVoiceName , studioOptions . voiceGroups ] ,
80113 ) ;
81114
82- const backendReady = status . state === "online" ;
83115 const subjectReady = subject . trim ( ) . length > 0 ;
84116 const scriptReady = script . trim ( ) . length > 0 ;
85117 const termsReady = terms . trim ( ) . length > 0 ;
@@ -100,38 +132,21 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
100132 } ;
101133 } , [ ] ) ;
102134
103- useEffect ( ( ) => {
104- selectedOptionsRef . current = { aspect, videoSource, voiceName } ;
105- } , [ aspect , videoSource , voiceName ] ) ;
106-
107135 useEffect ( ( ) => {
108136 if ( ! backendReady ) {
109- setOptionsData ( null ) ;
110137 return ;
111138 }
112139
113140 const controller = new AbortController ( ) ;
114- setOptionsError ( "" ) ;
141+ dispatchOptionsFetch ( { type : "loading" } ) ;
115142
116143 getOptions ( controller . signal )
117144 . then ( ( data ) => {
118- setOptionsData ( data ) ;
119- const nextOptions = normalizeStudioOptions ( data ) ;
120- const selectedOptions = selectedOptionsRef . current ;
121- if ( ! nextOptions . videoAspects . includes ( selectedOptions . aspect ) ) {
122- setAspect ( nextOptions . videoAspects [ 0 ] ) ;
123- }
124- if ( ! nextOptions . videoSources . includes ( selectedOptions . videoSource ) ) {
125- setVideoSource ( nextOptions . videoSources [ 0 ] ) ;
126- }
127- if ( ! nextOptions . voiceGroups . some ( ( group ) => group . voices . includes ( selectedOptions . voiceName ) ) ) {
128- setVoiceName ( getFirstVoice ( nextOptions . voiceGroups ) ) ;
129- }
145+ dispatchOptionsFetch ( { type : "loaded" , data } ) ;
130146 } )
131147 . catch ( ( error : unknown ) => {
132148 if ( ! controller . signal . aborted ) {
133- setOptionsData ( null ) ;
134- setOptionsError ( getErrorMessage ( error ) ) ;
149+ dispatchOptionsFetch ( { type : "error" , error : getErrorMessage ( error ) } ) ;
135150 }
136151 } ) ;
137152
@@ -289,9 +304,9 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
289304 videoLanguage : language ,
290305 paragraphNumber,
291306 termsAmount,
292- voiceName,
293- videoAspect : aspect ,
294- videoSource,
307+ voiceName : selectedVoiceName ,
308+ videoAspect : selectedVideoAspect ,
309+ videoSource : selectedVideoSource ,
295310 subtitleEnabled,
296311 } ;
297312 }
@@ -374,14 +389,14 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
374389 video_subject : subject . trim ( ) ,
375390 video_script : script . trim ( ) ,
376391 video_terms : parseTerms ( terms ) ,
377- video_aspect : aspect ,
392+ video_aspect : selectedVideoAspect ,
378393 video_concat_mode : "random" ,
379394 video_transition_mode : null ,
380395 video_clip_duration : 5 ,
381396 video_count : 1 ,
382- video_source : videoSource . trim ( ) ,
397+ video_source : selectedVideoSource . trim ( ) ,
383398 video_language : language . trim ( ) ,
384- voice_name : voiceName . trim ( ) ,
399+ voice_name : selectedVoiceName . trim ( ) ,
385400 voice_volume : 1 ,
386401 voice_rate : 1 ,
387402 bgm_type : "random" ,
@@ -427,7 +442,9 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
427442 < span >
428443 { optionsError
429444 ? `Options metadata unavailable, using safe fallback fields: ${ optionsError } `
430- : `Options loaded from ${ studioOptions . metadataSource === "backend" ? "/api/v1/options" : "local fallback" } .` }
445+ : optionsLoaded
446+ ? "Options loaded from /api/v1/options."
447+ : "Loading options metadata from /api/v1/options..." }
431448 </ span >
432449 </ output >
433450 ) : null }
@@ -540,15 +557,15 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
540557 < div className = "form-grid compact-form-grid" >
541558 < label htmlFor = "video-aspect" >
542559 Aspect
543- < select id = "video-aspect" value = { aspect } onChange = { ( event ) => setAspect ( event . target . value as StudioVideoAspect ) } >
560+ < select id = "video-aspect" value = { selectedVideoAspect } onChange = { ( event ) => setAspect ( event . target . value as StudioVideoAspect ) } >
544561 { studioOptions . videoAspects . map ( ( option ) => (
545562 < option value = { option } key = { option } > { formatVideoAspectLabel ( option ) } </ option >
546563 ) ) }
547564 </ select >
548565 </ label >
549566 < label htmlFor = "video-source" >
550567 Source
551- < select id = "video-source" value = { videoSource } onChange = { ( event ) => setVideoSource ( event . target . value as StudioVideoSource ) } >
568+ < select id = "video-source" value = { selectedVideoSource } onChange = { ( event ) => setVideoSource ( event . target . value as StudioVideoSource ) } >
552569 { studioOptions . videoSources . map ( ( option ) => (
553570 < option value = { option } key = { option } > { formatVideoSourceLabel ( option ) } </ option >
554571 ) ) }
@@ -559,7 +576,7 @@ export function StudioPage({ status, onTaskChange }: StudioPageProps) {
559576 { optionsError ? (
560577 < input id = "voice-name" value = { voiceName } onChange = { ( event ) => setVoiceName ( event . target . value ) } />
561578 ) : (
562- < select id = "voice-name" value = { voiceName } onChange = { ( event ) => setVoiceName ( event . target . value ) } >
579+ < select id = "voice-name" value = { selectedVoiceName } onChange = { ( event ) => setVoiceName ( event . target . value ) } >
563580 { voiceGroups . map ( ( group ) => (
564581 < optgroup label = { group . label } key = { group . id } >
565582 { group . voices . map ( ( voice ) => (
0 commit comments