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 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

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 { 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>} />

View File

@@ -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&apos;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>

View File

@@ -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 = {

View File

@@ -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',