Stop tracking generated story media and improve podcast workflow
This commit is contained in:
@@ -109,7 +109,8 @@ const CreateModal: React.FC<{
|
||||
onCreate: (payload: CreateProjectPayload) => void;
|
||||
open: boolean;
|
||||
defaultKnobs: Knobs;
|
||||
}> = ({ onCreate, open, defaultKnobs }) => {
|
||||
isSubmitting?: boolean;
|
||||
}> = ({ onCreate, open, defaultKnobs, isSubmitting = false }) => {
|
||||
const [idea, setIdea] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [speakers, setSpeakers] = useState<number>(1);
|
||||
@@ -119,8 +120,10 @@ const CreateModal: React.FC<{
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [knobs, setKnobs] = useState<Knobs>({ ...defaultKnobs });
|
||||
|
||||
const canSubmit = Boolean(idea || url);
|
||||
|
||||
const submit = () => {
|
||||
if (!idea && !url) return;
|
||||
if (!canSubmit || isSubmitting) return;
|
||||
onCreate({
|
||||
ideaOrUrl: idea || url,
|
||||
speakers,
|
||||
@@ -150,7 +153,7 @@ const CreateModal: React.FC<{
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300 mt-1">Enter a short idea or paste a blog URL. We'll analyze and suggest defaults.</p>
|
||||
</div>
|
||||
<PrimaryButton onClick={submit} ariaLabel="Analyze and continue">
|
||||
<PrimaryButton onClick={submit} ariaLabel="Analyze and continue" disabled={!canSubmit || isSubmitting}>
|
||||
Analyze & Continue
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
@@ -274,7 +277,9 @@ const CreateModal: React.FC<{
|
||||
>
|
||||
Reset
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={submit}>Analyze & Continue</PrimaryButton>
|
||||
<PrimaryButton onClick={submit} disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? "Analyzing..." : "Analyze & Continue"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -422,11 +427,13 @@ const SceneEditor: React.FC<{
|
||||
onUpdateScene: (s: Scene) => void;
|
||||
onApprove: (id: string) => Promise<void>;
|
||||
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
|
||||
}> = ({ scene, onUpdateScene, onApprove, onPreviewLine }) => {
|
||||
approvingSceneId?: string | null;
|
||||
}> = ({ scene, onUpdateScene, onApprove, onPreviewLine, approvingSceneId }) => {
|
||||
const updateLine = (updatedLine: Line) => {
|
||||
const updated = { ...scene, lines: scene.lines.map((l) => (l.id === updatedLine.id ? updatedLine : l)) };
|
||||
onUpdateScene(updated);
|
||||
};
|
||||
const approving = approvingSceneId === scene.id;
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
@@ -444,8 +451,9 @@ const SceneEditor: React.FC<{
|
||||
await onApprove(scene.id);
|
||||
onUpdateScene({ ...scene, approved: true });
|
||||
}}
|
||||
disabled={scene.approved || approving}
|
||||
>
|
||||
Approve Scene
|
||||
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,14 +477,20 @@ const ScriptEditor: React.FC<{
|
||||
durationMinutes: number;
|
||||
onBackToResearch: () => void;
|
||||
onProceedToRendering: (script: Script) => void;
|
||||
}> = ({ projectId, idea, research, rawResearch, knobs, speakers, durationMinutes, onBackToResearch, onProceedToRendering }) => {
|
||||
onError: (message: string) => void;
|
||||
}> = ({ projectId, idea, research, rawResearch, knobs, speakers, durationMinutes, onBackToResearch, onProceedToRendering, onError }) => {
|
||||
const [script, setScript] = useState<Script | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
|
||||
const showBusy = isAnalyzing || isResearching;
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setScript(null);
|
||||
setError(null);
|
||||
podcastApi
|
||||
.generateScript({
|
||||
projectId,
|
||||
@@ -493,7 +507,9 @@ const ScriptEditor: React.FC<{
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to generate script");
|
||||
const message = err instanceof Error ? err.message : "Failed to generate script";
|
||||
setError(message);
|
||||
onError(message);
|
||||
})
|
||||
.finally(() => mounted && setLoading(false));
|
||||
return () => {
|
||||
@@ -508,6 +524,7 @@ const ScriptEditor: React.FC<{
|
||||
|
||||
const approveScene = async (sceneId: string) => {
|
||||
try {
|
||||
setApprovingSceneId(sceneId);
|
||||
await podcastApi.approveScene({ projectId, sceneId });
|
||||
setScript((prev) =>
|
||||
prev
|
||||
@@ -520,7 +537,10 @@ const ScriptEditor: React.FC<{
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to approve scene";
|
||||
setError(message);
|
||||
onError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setApprovingSceneId((current) => (current === sceneId ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -546,7 +566,13 @@ const ScriptEditor: React.FC<{
|
||||
|
||||
{script.scenes.map((scene) => (
|
||||
<motion.div key={scene.id} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-3">
|
||||
<SceneEditor scene={scene} onUpdateScene={updateScene} onApprove={approveScene} onPreviewLine={(text) => podcastApi.previewLine(text)} />
|
||||
<SceneEditor
|
||||
scene={scene}
|
||||
onUpdateScene={updateScene}
|
||||
onApprove={approveScene}
|
||||
onPreviewLine={(text) => podcastApi.previewLine(text)}
|
||||
approvingSceneId={approvingSceneId}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
@@ -574,11 +600,13 @@ const RenderQueue: React.FC<{
|
||||
script: Script;
|
||||
knobs: Knobs;
|
||||
onBack: () => void;
|
||||
}> = ({ projectId, script, knobs, onBack }) => {
|
||||
onError: (message: string) => void;
|
||||
}> = ({ projectId, script, knobs, onBack, onError }) => {
|
||||
const [jobs, setJobs] = useState<Job[]>(
|
||||
script.scenes.map((s) => ({ sceneId: s.id, title: s.title, status: "idle", progress: 0, previewUrl: null, finalUrl: null, jobId: null }))
|
||||
);
|
||||
const [rendering, setRendering] = useState<string | null>(null);
|
||||
const isBusy = Boolean(rendering);
|
||||
|
||||
const getScene = (sceneId: string) => script.scenes.find((s) => s.id === sceneId);
|
||||
|
||||
@@ -609,6 +637,10 @@ const RenderQueue: React.FC<{
|
||||
progress: 100,
|
||||
previewUrl: mode === "preview" ? result.audioUrl : job.previewUrl,
|
||||
finalUrl: mode === "full" ? result.audioUrl : job.finalUrl,
|
||||
cost: result.cost,
|
||||
provider: result.provider,
|
||||
voiceId: result.voiceId,
|
||||
fileSize: result.fileSize,
|
||||
}
|
||||
: job
|
||||
)
|
||||
@@ -618,6 +650,8 @@ const RenderQueue: React.FC<{
|
||||
setJobs((list) =>
|
||||
list.map((job) => (job.sceneId === sceneId ? { ...job, status: "failed", progress: 0 } : job))
|
||||
);
|
||||
const message = error instanceof Error ? error.message : "Render failed";
|
||||
onError(message);
|
||||
} finally {
|
||||
setRendering(null);
|
||||
}
|
||||
@@ -654,6 +688,11 @@ const RenderQueue: React.FC<{
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{job.cost != null && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Cost: ${job.cost?.toFixed(2)} via {job.provider || "AI audio"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -672,8 +711,10 @@ const RenderQueue: React.FC<{
|
||||
<div className="mt-3 flex items-center gap-2 justify-end flex-wrap">
|
||||
{job.status === "idle" && (
|
||||
<>
|
||||
<SecondaryButton onClick={() => runRender(job.sceneId, "preview")}>Preview</SecondaryButton>
|
||||
<PrimaryButton onClick={() => runRender(job.sceneId, "full")} disabled={rendering === job.sceneId}>
|
||||
<SecondaryButton onClick={() => runRender(job.sceneId, "preview")} disabled={isBusy}>
|
||||
Preview
|
||||
</SecondaryButton>
|
||||
<PrimaryButton onClick={() => runRender(job.sceneId, "full")} disabled={isBusy}>
|
||||
Start Full Render
|
||||
</PrimaryButton>
|
||||
</>
|
||||
@@ -711,7 +752,8 @@ const PodcastDashboard: React.FC = () => {
|
||||
const [research, setResearch] = useState<Research | null>(null);
|
||||
const [rawResearch, setRawResearch] = useState<BlogResearchResponse | null>(null);
|
||||
const [estimate, setEstimate] = useState<PodcastEstimate | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isResearching, setIsResearching] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [showScriptEditor, setShowScriptEditor] = useState(false);
|
||||
const [showRenderQueue, setShowRenderQueue] = useState(false);
|
||||
@@ -728,8 +770,13 @@ const PodcastDashboard: React.FC = () => {
|
||||
}, [announcement]);
|
||||
|
||||
const handleCreate = async (payload: CreateProjectPayload) => {
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
try {
|
||||
setLoading(true);
|
||||
setIsAnalyzing(true);
|
||||
setAnnouncement("Analyzing your idea — AI suggestions incoming");
|
||||
const result = await podcastApi.createProject(payload);
|
||||
setProject({ id: result.projectId, idea: payload.ideaOrUrl, duration: payload.duration, speakers: payload.speakers });
|
||||
@@ -742,7 +789,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -751,9 +798,18 @@ const PodcastDashboard: React.FC = () => {
|
||||
setAnnouncement("Create a project first.");
|
||||
return;
|
||||
}
|
||||
if (selectedQueries.size === 0) {
|
||||
setAnnouncement("Select at least one query to research.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setIsResearching(true);
|
||||
setAnnouncement("Running grounded research — fetching sources");
|
||||
setResearch(null);
|
||||
setRawResearch(null);
|
||||
setScriptData(null);
|
||||
setShowScriptEditor(false);
|
||||
setShowRenderQueue(false);
|
||||
const approvedQueries = queries.filter((q) => selectedQueries.has(q.id));
|
||||
const { research: mapped, raw } = await podcastApi.runResearch({
|
||||
projectId: project.id,
|
||||
@@ -767,7 +823,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
} catch (error) {
|
||||
announceError(setAnnouncement, error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsResearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -777,6 +833,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
setScriptData(null);
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
};
|
||||
|
||||
@@ -786,7 +843,11 @@ const PodcastDashboard: React.FC = () => {
|
||||
setShowScriptEditor(false);
|
||||
};
|
||||
|
||||
const selectedCount = selectedQueries.size;
|
||||
const canGenerateScript = Boolean(project && research && rawResearch);
|
||||
|
||||
const toggleQuery = (id: string) => {
|
||||
if (isResearching) return;
|
||||
setSelectedQueries((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
@@ -812,9 +873,9 @@ const PodcastDashboard: React.FC = () => {
|
||||
<div className="mb-4 rounded bg-blue-50 text-blue-900 px-4 py-2 border border-blue-200 shadow">{announcement}</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="p-3 mb-4 bg-yellow-50 text-yellow-900 rounded border border-yellow-200">Working... please wait</div>}
|
||||
{showBusy && <div className="p-3 mb-4 bg-yellow-50 text-yellow-900 rounded border border-yellow-200">Working... please wait</div>}
|
||||
|
||||
{!project && <CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} />}
|
||||
{!project && <CreateModal open onCreate={handleCreate} defaultKnobs={DEFAULT_KNOBS} isSubmitting={isAnalyzing} />}
|
||||
|
||||
<div className="space-y-6">
|
||||
{analysis && !showScriptEditor && !showRenderQueue && <AnalysisPanel analysis={analysis} onRegenerate={() => setAnalysis({ ...analysis })} />}
|
||||
@@ -840,11 +901,15 @@ const PodcastDashboard: React.FC = () => {
|
||||
value={researchProvider}
|
||||
onChange={(e) => setResearchProvider(e.target.value as ResearchProvider)}
|
||||
className="bg-black/30 border border-white/10 rounded px-3 py-1 text-sm"
|
||||
disabled={isResearching}
|
||||
>
|
||||
<option value="google">Google Grounding</option>
|
||||
<option value="exa">Exa Neural Search</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Selected {selectedCount} / {queries.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{queries.map((q) => (
|
||||
@@ -863,7 +928,9 @@ const PodcastDashboard: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton onClick={handleRunResearch}>Run research</PrimaryButton>
|
||||
<PrimaryButton onClick={handleRunResearch} disabled={!project || selectedCount === 0 || isResearching}>
|
||||
{isResearching ? "Running..." : "Run research"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -874,7 +941,9 @@ const PodcastDashboard: React.FC = () => {
|
||||
<Card className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">Research summary</h3>
|
||||
<PrimaryButton onClick={handleGenerateScript}>Generate Script</PrimaryButton>
|
||||
<PrimaryButton onClick={handleGenerateScript} disabled={!canGenerateScript}>
|
||||
{canGenerateScript ? "Generate Script" : "Complete research to continue"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
<p className="text-sm text-gray-200">{research.summary}</p>
|
||||
{research.factCards.length > 0 && (
|
||||
@@ -888,7 +957,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScriptEditor && project && (
|
||||
{showScriptEditor && project && research && rawResearch && (
|
||||
<ScriptEditor
|
||||
projectId={project.id}
|
||||
idea={project.idea}
|
||||
@@ -899,9 +968,16 @@ const PodcastDashboard: React.FC = () => {
|
||||
durationMinutes={project.duration}
|
||||
onBackToResearch={() => setShowScriptEditor(false)}
|
||||
onProceedToRendering={(s) => handleProceedToRendering(s)}
|
||||
onError={(msg) => setAnnouncement(msg)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showScriptEditor && (!research || !rawResearch) && (
|
||||
<Card className="p-4 text-sm text-yellow-100 bg-yellow-900/30 border border-yellow-700">
|
||||
Complete a research run before opening the script editor.
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showRenderQueue && project && scriptData && (
|
||||
<RenderQueue
|
||||
projectId={project.id}
|
||||
@@ -911,6 +987,7 @@ const PodcastDashboard: React.FC = () => {
|
||||
setShowRenderQueue(false);
|
||||
setShowScriptEditor(true);
|
||||
}}
|
||||
onError={(msg) => setAnnouncement(msg)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,10 @@ export type Job = {
|
||||
previewUrl?: string | null;
|
||||
finalUrl?: string | null;
|
||||
jobId?: string | null;
|
||||
cost?: number | null;
|
||||
provider?: string | null;
|
||||
voiceId?: string | null;
|
||||
fileSize?: number | null;
|
||||
};
|
||||
|
||||
export type PodcastAnalysis = {
|
||||
|
||||
Reference in New Issue
Block a user