Skip to content

Commit 409e0bc

Browse files
Derive studio option selections
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent e69cc89 commit 409e0bc

4 files changed

Lines changed: 215 additions & 72 deletions

File tree

src/pages/SettingsPage.tsx

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from "react";
1+
import { useEffect, useMemo, useReducer, useState } from "react";
22
import { RotateCcw, Save, Undo2 } from "lucide-react";
33
import type { ApiStatus, BackendOptionsData } from "../api";
44
import { getOptions } from "../api";
@@ -22,7 +22,7 @@ import {
2222
import {
2323
ensureSelectedOption,
2424
ensureSelectedVoiceGroup,
25-
getFirstVoice,
25+
getEffectiveStudioOptionSelections,
2626
normalizeStudioOptions,
2727
} from "../studioOptions";
2828

@@ -39,6 +39,32 @@ type SettingsStorageSnapshot = {
3939
message: string;
4040
};
4141

42+
type OptionsFetchStatus = "idle" | "loading" | "loaded" | "error";
43+
44+
type OptionsFetchState = {
45+
status: OptionsFetchStatus;
46+
data: BackendOptionsData | null;
47+
error: string;
48+
};
49+
50+
type OptionsFetchAction =
51+
| { type: "loading" }
52+
| { type: "loaded"; data: BackendOptionsData }
53+
| { type: "error"; error: string };
54+
55+
const INITIAL_OPTIONS_FETCH_STATE: OptionsFetchState = { status: "idle", data: null, error: "" };
56+
57+
function optionsFetchReducer(_state: OptionsFetchState, action: OptionsFetchAction): OptionsFetchState {
58+
switch (action.type) {
59+
case "loading":
60+
return { status: "loading", data: null, error: "" };
61+
case "loaded":
62+
return { status: "loaded", data: action.data, error: "" };
63+
case "error":
64+
return { status: "error", data: null, error: action.error };
65+
}
66+
}
67+
4268
export function SettingsPage({ status, onRefresh }: SettingsPageProps) {
4369
const [initialStudioDefaults] = useState<SettingsStorageSnapshot>(() => getInitialStudioDefaults());
4470
const [language, setLanguage] = useState(initialStudioDefaults.settings.videoLanguage);
@@ -50,53 +76,42 @@ export function SettingsPage({ status, onRefresh }: SettingsPageProps) {
5076
const [subtitleEnabled, setSubtitleEnabled] = useState(initialStudioDefaults.settings.subtitleEnabled);
5177
const [storageState, setStorageState] = useState<SettingsStorageState>(initialStudioDefaults.storageState);
5278
const [settingsMessage, setSettingsMessage] = useState(initialStudioDefaults.message);
53-
const [optionsData, setOptionsData] = useState<BackendOptionsData | null>(null);
54-
const [optionsError, setOptionsError] = useState("");
55-
const selectedOptionsRef = useRef({ videoAspect, videoSource, voiceName });
79+
const [optionsFetchState, dispatchOptionsFetch] = useReducer(optionsFetchReducer, INITIAL_OPTIONS_FETCH_STATE);
5680
const storageBadgeClass = storageState === "saved" ? "status-online" : storageState === "corrupt" || storageState === "unavailable" ? "status-offline" : "status-checking";
5781
const backendReady = status.state === "online";
82+
const optionsFetchStatus = backendReady ? optionsFetchState.status : "idle";
83+
const optionsLoaded = optionsFetchStatus === "loaded";
84+
const optionsError = optionsFetchStatus === "error" ? optionsFetchState.error : "";
85+
const optionsData = optionsLoaded ? optionsFetchState.data : null;
5886
const studioOptions = useMemo(() => normalizeStudioOptions(optionsData), [optionsData]);
87+
const effectiveOptions = getEffectiveStudioOptionSelections(studioOptions, { videoAspect, videoSource, voiceName });
88+
const selectedVideoAspect = optionsLoaded || optionsError ? effectiveOptions.videoAspect : videoAspect;
89+
const selectedVideoSource = optionsLoaded || optionsError ? effectiveOptions.videoSource : videoSource;
90+
const selectedVoiceName = optionsLoaded ? effectiveOptions.voiceName : voiceName;
5991
const languageOptions = useMemo(
6092
() => ensureSelectedOption(studioOptions.languages, language, "Current language"),
6193
[language, studioOptions.languages],
6294
);
6395
const voiceGroups = useMemo(
64-
() => ensureSelectedVoiceGroup(studioOptions.voiceGroups, voiceName),
65-
[studioOptions.voiceGroups, voiceName],
96+
() => ensureSelectedVoiceGroup(studioOptions.voiceGroups, selectedVoiceName),
97+
[selectedVoiceName, studioOptions.voiceGroups],
6698
);
6799

68-
useEffect(() => {
69-
selectedOptionsRef.current = { videoAspect, videoSource, voiceName };
70-
}, [videoAspect, videoSource, voiceName]);
71-
72100
useEffect(() => {
73101
if (!backendReady) {
74-
setOptionsData(null);
75102
return;
76103
}
77104

78105
const controller = new AbortController();
79-
setOptionsError("");
106+
dispatchOptionsFetch({ type: "loading" });
80107

81108
getOptions(controller.signal)
82109
.then((data) => {
83-
setOptionsData(data);
84-
const nextOptions = normalizeStudioOptions(data);
85-
const selectedOptions = selectedOptionsRef.current;
86-
if (!nextOptions.videoAspects.includes(selectedOptions.videoAspect)) {
87-
setVideoAspect(nextOptions.videoAspects[0]);
88-
}
89-
if (!nextOptions.videoSources.includes(selectedOptions.videoSource)) {
90-
setVideoSource(nextOptions.videoSources[0]);
91-
}
92-
if (!nextOptions.voiceGroups.some((group) => group.voices.includes(selectedOptions.voiceName))) {
93-
setVoiceName(getFirstVoice(nextOptions.voiceGroups));
94-
}
110+
dispatchOptionsFetch({ type: "loaded", data });
95111
})
96112
.catch((error: unknown) => {
97113
if (!controller.signal.aborted) {
98-
setOptionsData(null);
99-
setOptionsError(getErrorMessage(error));
114+
dispatchOptionsFetch({ type: "error", error: getErrorMessage(error) });
100115
}
101116
});
102117

@@ -108,9 +123,9 @@ export function SettingsPage({ status, onRefresh }: SettingsPageProps) {
108123
videoLanguage: language,
109124
paragraphNumber,
110125
termsAmount,
111-
voiceName,
112-
videoAspect,
113-
videoSource,
126+
voiceName: selectedVoiceName,
127+
videoAspect: selectedVideoAspect,
128+
videoSource: selectedVideoSource,
114129
subtitleEnabled,
115130
};
116131
}
@@ -275,15 +290,15 @@ export function SettingsPage({ status, onRefresh }: SettingsPageProps) {
275290
<div className="form-grid compact-form-grid">
276291
<label htmlFor="settings-video-aspect">
277292
Aspect
278-
<select id="settings-video-aspect" value={videoAspect} onChange={(event) => setVideoAspect(event.target.value as StudioVideoAspect)}>
293+
<select id="settings-video-aspect" value={selectedVideoAspect} onChange={(event) => setVideoAspect(event.target.value as StudioVideoAspect)}>
279294
{studioOptions.videoAspects.map((option) => (
280295
<option value={option} key={option}>{formatVideoAspectLabel(option)}</option>
281296
))}
282297
</select>
283298
</label>
284299
<label htmlFor="settings-video-source">
285300
Source
286-
<select id="settings-video-source" value={videoSource} onChange={(event) => setVideoSource(event.target.value as StudioVideoSource)}>
301+
<select id="settings-video-source" value={selectedVideoSource} onChange={(event) => setVideoSource(event.target.value as StudioVideoSource)}>
287302
{studioOptions.videoSources.map((option) => (
288303
<option value={option} key={option}>{formatVideoSourceLabel(option)}</option>
289304
))}
@@ -294,7 +309,7 @@ export function SettingsPage({ status, onRefresh }: SettingsPageProps) {
294309
{optionsError ? (
295310
<input id="settings-voice-name" value={voiceName} onChange={(event) => setVoiceName(event.target.value)} />
296311
) : (
297-
<select id="settings-voice-name" value={voiceName} onChange={(event) => setVoiceName(event.target.value)}>
312+
<select id="settings-voice-name" value={selectedVoiceName} onChange={(event) => setVoiceName(event.target.value)}>
298313
{voiceGroups.map((group) => (
299314
<optgroup label={group.label} key={group.id}>
300315
{group.voices.map((voice) => (
@@ -310,7 +325,9 @@ export function SettingsPage({ status, onRefresh }: SettingsPageProps) {
310325
<p className={`form-alert ${optionsError ? "form-alert-error" : "form-alert-info"}`}>
311326
{optionsError
312327
? `Options metadata unavailable, manual fallback active: ${optionsError}`
313-
: `Language, voice, aspect, and source choices use ${studioOptions.metadataSource === "backend" ? "/api/v1/options" : "local fallback"}.`}
328+
: optionsLoaded
329+
? "Language, voice, aspect, and source choices use /api/v1/options."
330+
: "Loading options metadata from /api/v1/options..."}
314331
</p>
315332

316333
<label className="toggle-row" htmlFor="settings-subtitle-enabled">

src/pages/StudioPage.tsx

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useMemo, useRef, useState } from "react";
1+
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
22
import { AlertCircle, Loader2, PlayCircle, RotateCcw, Save, Sparkles, Undo2, Wand2 } from "lucide-react";
33
import type { ApiStatus, BackendOptionsData, CreateVideoPayload } from "../api";
44
import { createVideo, generateScript, generateTerms, getOptions, getTask } from "../api";
@@ -25,7 +25,7 @@ import {
2525
import {
2626
ensureSelectedOption,
2727
ensureSelectedVoiceGroup,
28-
getFirstVoice,
28+
getEffectiveStudioOptionSelections,
2929
normalizeStudioOptions,
3030
} from "../studioOptions";
3131
import {
@@ -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+
4773
export 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

Comments
 (0)