Stop tracking generated story media and improve podcast workflow
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 967 KiB |
|
Before Width: | Height: | Size: 1020 KiB |
|
Before Width: | Height: | Size: 1017 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -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>} />
|
||||||
|
|||||||
@@ -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'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'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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||