Stop tracking generated story media and improve podcast workflow

This commit is contained in:
ajaysi
2025-11-28 16:01:53 +05:30
parent 7dd25d08af
commit 31f078c763
42 changed files with 177 additions and 32 deletions

View File

@@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel, Field
from middleware.auth_middleware import get_current_user from middleware.auth_middleware import get_current_user
from models.story_models import ( from models.story_models import (
@@ -20,7 +20,56 @@ from ..utils.auth import require_authenticated_user
router = APIRouter() router = APIRouter()
story_service = StoryWriterService() story_service = StoryWriterService()
scene_approval_store: Dict[str, Dict[str, Any]] = {} scene_approval_store: Dict[str, Dict[str, Dict[str, Dict[str, Any]]]] = {}
APPROVAL_TTL_SECONDS = 60 * 60 * 24
MAX_APPROVALS_PER_USER = 200
def _cleanup_user_approvals(user_id: str) -> None:
user_store = scene_approval_store.get(user_id)
if not user_store:
return
now = datetime.utcnow()
for project_id in list(user_store.keys()):
scenes = user_store.get(project_id, {})
for scene_id in list(scenes.keys()):
timestamp = scenes[scene_id].get("timestamp")
if isinstance(timestamp, datetime):
if (now - timestamp).total_seconds() > APPROVAL_TTL_SECONDS:
scenes.pop(scene_id, None)
if not scenes:
user_store.pop(project_id, None)
if not user_store:
scene_approval_store.pop(user_id, None)
def _enforce_capacity(user_id: str) -> None:
user_store = scene_approval_store.get(user_id)
if not user_store:
return
entries: List[tuple[datetime, str, str]] = []
for project_id, scenes in user_store.items():
for scene_id, meta in scenes.items():
timestamp = meta.get("timestamp")
if isinstance(timestamp, datetime):
entries.append((timestamp, project_id, scene_id))
if len(entries) <= MAX_APPROVALS_PER_USER:
return
entries.sort(key=lambda item: item[0])
to_remove = len(entries) - MAX_APPROVALS_PER_USER
for i in range(to_remove):
_, project_id, scene_id = entries[i]
scenes = user_store.get(project_id)
if not scenes:
continue
scenes.pop(scene_id, None)
if not scenes:
user_store.pop(project_id, None)
def _get_user_store(user_id: str) -> Dict[str, Dict[str, Dict[str, Any]]]:
_cleanup_user_approvals(user_id)
return scene_approval_store.setdefault(user_id, {})
@router.post("/generate-start", response_model=StoryContentResponse) @router.post("/generate-start", response_model=StoryContentResponse)
@@ -197,8 +246,8 @@ async def continue_story(
class SceneApprovalRequest(BaseModel): class SceneApprovalRequest(BaseModel):
project_id: str project_id: str = Field(..., min_length=1)
scene_id: str scene_id: str = Field(..., min_length=1)
approved: bool = True approved: bool = True
notes: Optional[str] = None notes: Optional[str] = None
@@ -211,13 +260,20 @@ async def approve_script_scene(
"""Persist scene approval metadata for auditing.""" """Persist scene approval metadata for auditing."""
try: try:
user_id = require_authenticated_user(current_user) user_id = require_authenticated_user(current_user)
approvals = scene_approval_store.setdefault(request.project_id, {}) if not request.project_id.strip() or not request.scene_id.strip():
approvals[request.scene_id] = { raise HTTPException(status_code=400, detail="project_id and scene_id are required")
notes = request.notes.strip() if request.notes else None
user_store = _get_user_store(user_id)
project_store = user_store.setdefault(request.project_id, {})
timestamp = datetime.utcnow()
project_store[request.scene_id] = {
"approved": request.approved, "approved": request.approved,
"notes": request.notes, "notes": notes,
"user_id": user_id, "user_id": user_id,
"timestamp": datetime.utcnow().isoformat(), "timestamp": timestamp,
} }
_enforce_capacity(user_id)
logger.info( logger.info(
"[StoryWriter] Scene approval recorded user=%s project=%s scene=%s approved=%s", "[StoryWriter] Scene approval recorded user=%s project=%s scene=%s approved=%s",
user_id, user_id,
@@ -230,6 +286,7 @@ async def approve_script_scene(
"project_id": request.project_id, "project_id": request.project_id,
"scene_id": request.scene_id, "scene_id": request.scene_id,
"approved": request.approved, "approved": request.approved,
"timestamp": timestamp.isoformat(),
} }
except HTTPException: except HTTPException:
raise raise

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -14,7 +14,6 @@ import BlogWriter from './components/BlogWriter/BlogWriter';
import StoryWriter from './components/StoryWriter/StoryWriter'; import StoryWriter from './components/StoryWriter/StoryWriter';
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio'; import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
import { ProductMarketingDashboard } from './components/ProductMarketing'; import { ProductMarketingDashboard } from './components/ProductMarketing';
import { ProductPhotoshootStudio } from './components/ProductMarketing/ProductPhotoshootStudio';
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard'; import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
import PricingPage from './components/Pricing/PricingPage'; import PricingPage from './components/Pricing/PricingPage';
import WixTestPage from './components/WixTestPage/WixTestPage'; import WixTestPage from './components/WixTestPage/WixTestPage';
@@ -463,7 +462,6 @@ const App: React.FC = () => {
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} /> <Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} /> <Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} /> <Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
<Route path="/campaign-creator/photoshoot" element={<ProtectedRoute><ProductPhotoshootStudio /></ProtectedRoute>} />
<Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} /> <Route path="/product-marketing" element={<Navigate to="/campaign-creator" replace />} />
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} /> <Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} /> <Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />

View File

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

View File

@@ -68,6 +68,10 @@ export type Job = {
previewUrl?: string | null; previewUrl?: string | null;
finalUrl?: string | null; finalUrl?: string | null;
jobId?: string | null; jobId?: string | null;
cost?: number | null;
provider?: string | null;
voiceId?: string | null;
fileSize?: number | null;
}; };
export type PodcastAnalysis = { export type PodcastAnalysis = {

View File

@@ -47,6 +47,15 @@ export const toolCategories: ToolCategories = {
features: ['Structured Outline', 'Image Generation', 'Audio Narration', 'Story Video'], features: ['Structured Outline', 'Image Generation', 'Audio Narration', 'Story Video'],
isHighlighted: true isHighlighted: true
}, },
{
name: 'Podcast Maker',
description: 'Generate research-grounded podcast scripts and audio',
icon: React.createElement(AudioIcon),
status: 'beta',
path: '/podcast-maker',
features: ['Research Workflow', 'Editable Script', 'Scene Approvals', 'WaveSpeed Audio'],
isHighlighted: true
},
{ {
name: 'Image Generator', name: 'Image Generator',
description: 'AI image creation and visual content generation', description: 'AI image creation and visual content generation',