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 loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from middleware.auth_middleware import get_current_user
|
||||
from models.story_models import (
|
||||
@@ -20,7 +20,56 @@ from ..utils.auth import require_authenticated_user
|
||||
|
||||
router = APIRouter()
|
||||
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)
|
||||
@@ -197,8 +246,8 @@ async def continue_story(
|
||||
|
||||
|
||||
class SceneApprovalRequest(BaseModel):
|
||||
project_id: str
|
||||
scene_id: str
|
||||
project_id: str = Field(..., min_length=1)
|
||||
scene_id: str = Field(..., min_length=1)
|
||||
approved: bool = True
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -211,13 +260,20 @@ async def approve_script_scene(
|
||||
"""Persist scene approval metadata for auditing."""
|
||||
try:
|
||||
user_id = require_authenticated_user(current_user)
|
||||
approvals = scene_approval_store.setdefault(request.project_id, {})
|
||||
approvals[request.scene_id] = {
|
||||
if not request.project_id.strip() or not request.scene_id.strip():
|
||||
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,
|
||||
"notes": request.notes,
|
||||
"notes": notes,
|
||||
"user_id": user_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
_enforce_capacity(user_id)
|
||||
logger.info(
|
||||
"[StoryWriter] Scene approval recorded user=%s project=%s scene=%s approved=%s",
|
||||
user_id,
|
||||
@@ -230,6 +286,7 @@ async def approve_script_scene(
|
||||
"project_id": request.project_id,
|
||||
"scene_id": request.scene_id,
|
||||
"approved": request.approved,
|
||||
"timestamp": timestamp.isoformat(),
|
||||
}
|
||||
except HTTPException:
|
||||
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 { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import { ProductMarketingDashboard } from './components/ProductMarketing';
|
||||
import { ProductPhotoshootStudio } from './components/ProductMarketing/ProductPhotoshootStudio';
|
||||
import PodcastDashboard from './components/PodcastMaker/PodcastDashboard';
|
||||
import PricingPage from './components/Pricing/PricingPage';
|
||||
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="/asset-library" element={<ProtectedRoute><AssetLibrary /></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="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -47,6 +47,15 @@ export const toolCategories: ToolCategories = {
|
||||
features: ['Structured Outline', 'Image Generation', 'Audio Narration', 'Story Video'],
|
||||
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',
|
||||
description: 'AI image creation and visual content generation',
|
||||
|
||||