Podcast Maker: Fix progress modals, research JSON, header stepper, voice/podcastMode chips
This commit is contained in:
@@ -66,15 +66,15 @@ export const SceneEditor: React.FC<SceneEditorProps> = ({
|
||||
const [showAudioModal, setShowAudioModal] = useState(false);
|
||||
const [showImagePreview, setShowImagePreview] = useState(false);
|
||||
const [audioSettings, setAudioSettings] = useState<AudioGenerationSettings>({
|
||||
voiceId: "Wise_Woman",
|
||||
customVoiceId: undefined,
|
||||
speed: 1.0,
|
||||
voiceId: knobs.voice_id || "Wise_Woman",
|
||||
customVoiceId: knobs.custom_voice_id || undefined,
|
||||
speed: knobs.voice_speed ?? 1.0,
|
||||
volume: 1.0,
|
||||
pitch: 0.0,
|
||||
emotion: scene.emotion || "neutral",
|
||||
emotion: scene.emotion || knobs.voice_emotion || "neutral",
|
||||
englishNormalization: true,
|
||||
sampleRate: 24000,
|
||||
bitrate: 64000,
|
||||
sampleRate: knobs.sample_rate || 24000,
|
||||
bitrate: knobs.bitrate === 'hd' ? 128000 : 64000,
|
||||
channel: "1",
|
||||
format: "mp3",
|
||||
languageBoost: "auto",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { Box, Typography, Stack, Chip } from "@mui/material";
|
||||
import {
|
||||
EditNote as EditNoteIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
RadioButtonUnchecked as RadioButtonUncheckedIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Scene } from "../../types";
|
||||
|
||||
interface SceneEditorHeaderProps {
|
||||
scene: Scene;
|
||||
}
|
||||
|
||||
export const SceneEditorHeader: React.FC<SceneEditorHeaderProps> = ({ scene }) => {
|
||||
return (
|
||||
<>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1, color: "#0f172a", fontWeight: 600 }}
|
||||
>
|
||||
<EditNoteIcon fontSize="small" sx={{ color: "#667eea", fontSize: "1.5rem" }} />
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" flexWrap="wrap">
|
||||
<Chip
|
||||
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
|
||||
label={scene.approved ? "Approved" : "Pending Approval"}
|
||||
size="small"
|
||||
color={scene.approved ? "success" : "warning"}
|
||||
sx={{
|
||||
background: scene.approved
|
||||
? "linear-gradient(135deg, rgba(16, 185, 129, 0.12) 0%, rgba(5, 150, 105, 0.12) 100%)"
|
||||
: "linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(217, 119, 6, 0.12) 100%)",
|
||||
color: scene.approved ? "#059669" : "#d97706",
|
||||
border: scene.approved
|
||||
? "1px solid rgba(16, 185, 129, 0.25)"
|
||||
: "1px solid rgba(245, 158, 11, 0.25)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
height: 26,
|
||||
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontWeight: 500, fontSize: "0.8125rem" }}>
|
||||
Duration: {scene.duration}s
|
||||
</Typography>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { Box, Divider, Stack, Typography, CircularProgress } from "@mui/material";
|
||||
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
|
||||
|
||||
interface SceneEditorMediaPanelProps {
|
||||
hasAudio: boolean;
|
||||
audioBlobUrl?: string | null;
|
||||
isGenerating?: boolean;
|
||||
}
|
||||
|
||||
// Minimal media panel wrapper extracted for refactor hygiene
|
||||
export const SceneEditorMediaPanel: React.FC<SceneEditorMediaPanelProps> = ({ hasAudio, audioBlobUrl, isGenerating }) => {
|
||||
return (
|
||||
<Box sx={{ mt: 1, p: 2, borderRadius: 2, border: "1px solid rgba(0,0,0,0.08)", background: "#fff" }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
||||
<VolumeUpIcon sx={{ color: hasAudio ? "#059669" : "#d97706" }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: hasAudio ? "#059669" : "#d97706" }}>
|
||||
{hasAudio ? "Audio Generated" : "Loading Audio..."}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{audioBlobUrl ? (
|
||||
<audio controls src={audioBlobUrl} style={{ width: "100%" }} />
|
||||
) : isGenerating ? (
|
||||
<CircularProgress size={20} />
|
||||
) : null}
|
||||
<Divider sx={{ mt: 2 }} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha, Collapse, IconButton, Divider, Chip, Tooltip } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon, Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon, Download as DownloadIcon, Refresh as RefreshIcon, Mic as MicIcon } from "@mui/icons-material";
|
||||
import { Script, Knobs, Scene } from "../types";
|
||||
import { BlogResearchResponse } from "../../../services/blogWriterApi";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
@@ -300,6 +300,27 @@ export const ScriptEditor: React.FC<ScriptEditorProps> = ({
|
||||
>
|
||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||
Script Editor
|
||||
{knobs.voice_id && (() => {
|
||||
const vid = knobs.voice_id;
|
||||
const isCustom = Boolean(vid && !vid.startsWith("builtin:") && !["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman", "Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man", "Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess", "Sweet_Girl_2", "Exuberant_Girl"].includes(vid));
|
||||
const vName = isCustom ? "My Voice Clone" : (vid === "Wise_Woman" ? "Wise Woman" : vid === "Friendly_Person" ? "Friendly Person" : vid === "Deep_Voice_Man" ? "Deep Voice Man" : vid?.replace(/_/g, " ") || "Default");
|
||||
return (
|
||||
<Chip
|
||||
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
|
||||
label={`Active Voice: ${vName}`}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
background: isCustom ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
|
||||
color: isCustom ? "#10b981" : "#6366f1",
|
||||
border: `1px solid ${isCustom ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
|
||||
'& .MuiChip-icon': { color: isCustom ? "#10b981" : "#6366f1" },
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||
Review and refine your podcast script before rendering
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||
import { Script, Knobs, Scene, PodcastMode } from "../types";
|
||||
import { podcastApi } from "../../../services/podcastApi";
|
||||
|
||||
interface ScriptEditorContextType {
|
||||
// State
|
||||
script: Script | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
podcastMode: PodcastMode;
|
||||
approvingSceneId: string | null;
|
||||
generatingAudioId: string | null;
|
||||
showScriptFormatInfo: boolean;
|
||||
combiningAudio: boolean;
|
||||
scriptTab: "audio" | "video";
|
||||
combinedAudioResult: { url: string; filename: string; duration: number; sceneCount: number } | null;
|
||||
generatingBatchAudio: boolean;
|
||||
batchAudioProgress: { completed: number; total: number } | null;
|
||||
generatingChartId: string | null; // B-roll: generating chart preview
|
||||
|
||||
// Computed
|
||||
activeScript: Script | null;
|
||||
allApproved: boolean | null;
|
||||
approvedCount: number;
|
||||
totalScenes: number;
|
||||
allScenesHaveAudio: boolean | null;
|
||||
scenesWithAudio: number;
|
||||
allScenesHaveAudioAndImages: boolean | null;
|
||||
needsAudioGeneration: boolean | null;
|
||||
scenesWithCharts: number; // B-roll: count of scenes with chart data
|
||||
|
||||
// Setters for UI state
|
||||
setScript: React.Dispatch<React.SetStateAction<Script | null>>;
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setApprovingSceneId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setGeneratingAudioId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
setShowScriptFormatInfo: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setCombiningAudio: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setScriptTab: React.Dispatch<React.SetStateAction<"audio" | "video">>;
|
||||
setCombinedAudioResult: React.Dispatch<React.SetStateAction<{ url: string; filename: string; duration: number; sceneCount: number } | null>>;
|
||||
setGeneratingBatchAudio: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setBatchAudioProgress: React.Dispatch<React.SetStateAction<{ completed: number; total: number } | null>>;
|
||||
setGeneratingChartId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
|
||||
// Actions
|
||||
updateScene: (updated: Scene) => void;
|
||||
approveScene: (sceneId: string) => Promise<void>;
|
||||
deleteScene: (sceneId: string) => void;
|
||||
generateAllAudio: () => Promise<void>;
|
||||
combineAudio: () => Promise<void>;
|
||||
emitScriptChange: (next: Script) => void;
|
||||
// B-roll actions
|
||||
generateChartPreviews: () => Promise<void>;
|
||||
regenerateChart: (sceneId: string) => Promise<void>;
|
||||
removeChart: (sceneId: string) => void;
|
||||
}
|
||||
|
||||
const ScriptEditorContext = createContext<ScriptEditorContextType | undefined>(undefined);
|
||||
|
||||
interface ScriptEditorProviderProps {
|
||||
children: ReactNode;
|
||||
projectId: string;
|
||||
idea: string;
|
||||
rawResearch: any;
|
||||
knobs: Knobs;
|
||||
speakers: number;
|
||||
durationMinutes: number;
|
||||
initialScript: Script | null;
|
||||
initialAudioScript?: Script | null;
|
||||
initialVideoScript?: Script | null;
|
||||
podcastMode?: PodcastMode;
|
||||
analysis?: any;
|
||||
outline?: any;
|
||||
onScriptChange: (script: Script) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export const ScriptEditorProvider: React.FC<ScriptEditorProviderProps> = ({
|
||||
children,
|
||||
projectId,
|
||||
idea,
|
||||
rawResearch,
|
||||
knobs,
|
||||
speakers,
|
||||
durationMinutes,
|
||||
initialScript,
|
||||
initialAudioScript,
|
||||
initialVideoScript,
|
||||
podcastMode = "video_only",
|
||||
analysis,
|
||||
outline,
|
||||
onScriptChange,
|
||||
onError,
|
||||
}) => {
|
||||
// Core state
|
||||
const [script, setScript] = useState<Script | null>(initialScript);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// UI state
|
||||
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
|
||||
const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
|
||||
const [showScriptFormatInfo, setShowScriptFormatInfo] = useState(false);
|
||||
const [combiningAudio, setCombiningAudio] = useState(false);
|
||||
const [scriptTab, setScriptTab] = useState<"audio" | "video">("video");
|
||||
const [combinedAudioResult, setCombinedAudioResult] = useState<{
|
||||
url: string;
|
||||
filename: string;
|
||||
duration: number;
|
||||
sceneCount: number;
|
||||
} | null>(null);
|
||||
const [generatingBatchAudio, setGeneratingBatchAudio] = useState(false);
|
||||
const [batchAudioProgress, setBatchAudioProgress] = useState<{ completed: number; total: number } | null>(null);
|
||||
const [generatingChartId, setGeneratingChartId] = useState<string | null>(null);
|
||||
|
||||
// Emit script changes to parent (deferred to avoid setState during render)
|
||||
const emitScriptChange = useCallback(
|
||||
(next: Script) => {
|
||||
Promise.resolve().then(() => onScriptChange(next));
|
||||
},
|
||||
[onScriptChange]
|
||||
);
|
||||
|
||||
// Determine which script to display based on mode and tab
|
||||
const getActiveScript = (): Script | null => {
|
||||
const currentScript = script || null;
|
||||
|
||||
if (podcastMode === "audio_only") {
|
||||
if (currentScript?.audioScript) {
|
||||
return { scenes: currentScript.audioScript };
|
||||
}
|
||||
return initialAudioScript || null;
|
||||
}
|
||||
|
||||
if (podcastMode === "video_only") {
|
||||
return currentScript || initialVideoScript || null;
|
||||
}
|
||||
|
||||
if (podcastMode === "audio_video") {
|
||||
if (scriptTab === "audio") {
|
||||
if (currentScript?.audioScript) {
|
||||
return { scenes: currentScript.audioScript };
|
||||
}
|
||||
return initialAudioScript || null;
|
||||
} else {
|
||||
if (currentScript?.videoScript) {
|
||||
return { scenes: currentScript.videoScript };
|
||||
}
|
||||
return currentScript || initialVideoScript || null;
|
||||
}
|
||||
}
|
||||
|
||||
return currentScript || initialVideoScript || null;
|
||||
};
|
||||
|
||||
const activeScript = getActiveScript();
|
||||
|
||||
// Computed values
|
||||
const allApproved = activeScript && activeScript.scenes.every((s) => s.approved);
|
||||
const approvedCount = activeScript ? activeScript.scenes.filter((s) => s.approved).length : 0;
|
||||
const totalScenes = activeScript ? activeScript.scenes.length : 0;
|
||||
const allScenesHaveAudio = activeScript && activeScript.scenes.every((s) => s.audioUrl);
|
||||
const scenesWithAudio = activeScript ? activeScript.scenes.filter((s) => s.audioUrl).length : 0;
|
||||
const allScenesHaveAudioAndImages = activeScript && (
|
||||
podcastMode === "audio_only"
|
||||
? activeScript.scenes.every((s) => s.audioUrl)
|
||||
: activeScript.scenes.every((s) => s.audioUrl && s.imageUrl)
|
||||
);
|
||||
const needsAudioGeneration = activeScript && !allScenesHaveAudio && activeScript.scenes.some((s) => !s.audioUrl);
|
||||
|
||||
// B-roll computed
|
||||
const scenesWithCharts = activeScript ? activeScript.scenes.filter((s) => s.chart_data && Object.keys(s.chart_data).length > 0).length : 0;
|
||||
|
||||
// Sync with parent state
|
||||
useEffect(() => {
|
||||
if (initialScript) {
|
||||
setScript(initialScript);
|
||||
}
|
||||
}, [initialScript]);
|
||||
|
||||
// Generate script effect - only if not already generated by parent
|
||||
// This prevents duplicate API calls when both parent workflow and this component try to generate
|
||||
useEffect(() => {
|
||||
// Skip if parent already provided script via props
|
||||
if (script || initialScript) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawResearch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if podcastMode is audio_only (script should be passed from parent for audio_only)
|
||||
// Parent workflow already generates the script, we just display it here
|
||||
if (podcastMode === "audio_only") {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
podcastApi
|
||||
.generateScript({
|
||||
projectId,
|
||||
idea,
|
||||
research: rawResearch,
|
||||
knobs,
|
||||
speakers,
|
||||
durationMinutes,
|
||||
analysis,
|
||||
outline,
|
||||
})
|
||||
.then((res) => {
|
||||
if (mounted) {
|
||||
setScript(res);
|
||||
emitScriptChange(res);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to generate script";
|
||||
setError(message);
|
||||
onError(message);
|
||||
})
|
||||
.finally(() => mounted && setLoading(false));
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [projectId, rawResearch, idea, knobs, speakers, durationMinutes, analysis, outline, emitScriptChange, onError, script]);
|
||||
|
||||
const updateScene = (updated: Scene) => {
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScript = {
|
||||
...currentScript,
|
||||
scenes: currentScript.scenes.map((s) => (s.id === updated.id ? { ...s, ...updated } : s))
|
||||
};
|
||||
emitScriptChange(updatedScript);
|
||||
return updatedScript;
|
||||
});
|
||||
};
|
||||
|
||||
const approveScene = async (sceneId: string) => {
|
||||
try {
|
||||
setApprovingSceneId(sceneId);
|
||||
await podcastApi.approveScene({ projectId, sceneId });
|
||||
setScript((currentScript) => {
|
||||
if (!currentScript) return currentScript;
|
||||
const updatedScript = {
|
||||
...currentScript,
|
||||
scenes: currentScript.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
|
||||
};
|
||||
emitScriptChange(updatedScript);
|
||||
return updatedScript;
|
||||
});
|
||||
} 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));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteScene = useCallback((sceneId: string) => {
|
||||
if (!activeScript) return;
|
||||
|
||||
if (activeScript.scenes.length <= 1) {
|
||||
onError("Cannot delete the last scene. At least one scene is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneToDelete = activeScript.scenes.find(s => s.id === sceneId);
|
||||
if (!sceneToDelete) return;
|
||||
|
||||
const confirmDelete = window.confirm(
|
||||
`Are you sure you want to delete "${sceneToDelete.title}"? This action cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmDelete) return;
|
||||
|
||||
const updatedScenes = activeScript.scenes.filter(s => s.id !== sceneId);
|
||||
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||
|
||||
emitScriptChange(updatedScript);
|
||||
setScript(updatedScript);
|
||||
}, [activeScript, emitScriptChange, onError]);
|
||||
|
||||
const generateAllAudio = useCallback(async () => {
|
||||
if (!activeScript || !projectId || !knobs) return;
|
||||
|
||||
const scenesNeedingAudio = activeScript.scenes.filter((s) => !s.audioUrl);
|
||||
if (scenesNeedingAudio.length === 0) {
|
||||
onError("All scenes already have audio generated.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGeneratingBatchAudio(true);
|
||||
setBatchAudioProgress({ completed: 0, total: scenesNeedingAudio.length });
|
||||
|
||||
const sceneData = scenesNeedingAudio.map((scene) => ({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
lines: scene.lines.map((line) => ({ text: line.text })),
|
||||
}));
|
||||
|
||||
const result = await podcastApi.generateBatchAudio({
|
||||
scenes: sceneData,
|
||||
voiceId: knobs.voice_id,
|
||||
customVoiceId: knobs.custom_voice_id,
|
||||
speed: knobs.voice_speed,
|
||||
emotion: knobs.voice_emotion,
|
||||
englishNormalization: true,
|
||||
});
|
||||
|
||||
const updatedScenes = activeScript.scenes.map((scene) => {
|
||||
const batchResult = result.results.find((r: any) => r.sceneId === scene.id);
|
||||
if (batchResult) {
|
||||
return { ...scene, audioUrl: batchResult.audioUrl };
|
||||
}
|
||||
return scene;
|
||||
});
|
||||
|
||||
await emitScriptChange({ ...activeScript, scenes: updatedScenes });
|
||||
setBatchAudioProgress({ completed: scenesNeedingAudio.length, total: scenesNeedingAudio.length });
|
||||
} catch (error: any) {
|
||||
console.error("Batch audio generation failed:", error);
|
||||
onError(`Failed to generate audio: ${error.message || error}`);
|
||||
} finally {
|
||||
setGeneratingBatchAudio(false);
|
||||
setBatchAudioProgress(null);
|
||||
}
|
||||
}, [activeScript, projectId, knobs, emitScriptChange, onError]);
|
||||
|
||||
const combineAudio = useCallback(async () => {
|
||||
if (!activeScript || !projectId) return;
|
||||
|
||||
try {
|
||||
setCombiningAudio(true);
|
||||
|
||||
const sceneIds: string[] = [];
|
||||
const sceneAudioUrls: string[] = [];
|
||||
|
||||
activeScript.scenes.forEach((scene) => {
|
||||
if (scene.audioUrl) {
|
||||
const audioUrl = scene.audioUrl.startsWith('blob:') ? '' : scene.audioUrl;
|
||||
if (audioUrl) {
|
||||
sceneIds.push(scene.id);
|
||||
sceneAudioUrls.push(audioUrl);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (sceneIds.length === 0) {
|
||||
onError("No audio files found to combine.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await podcastApi.combineAudio({
|
||||
projectId,
|
||||
sceneIds,
|
||||
sceneAudioUrls,
|
||||
});
|
||||
|
||||
setCombinedAudioResult({
|
||||
url: result.combined_audio_url,
|
||||
filename: result.combined_audio_filename,
|
||||
duration: result.total_duration,
|
||||
sceneCount: result.scene_count,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to combine audio";
|
||||
onError(`Failed to combine audio: ${message}`);
|
||||
} finally {
|
||||
setCombiningAudio(false);
|
||||
}
|
||||
}, [activeScript, projectId, onError]);
|
||||
|
||||
// =====================
|
||||
// B-Roll Actions
|
||||
// =====================
|
||||
|
||||
const generateChartPreviews = useCallback(async () => {
|
||||
if (!activeScript) return;
|
||||
|
||||
const scenesWithData = activeScript.scenes.filter(
|
||||
(s) => s.chart_data && Object.keys(s.chart_data).length > 0
|
||||
);
|
||||
|
||||
if (scenesWithData.length === 0) {
|
||||
onError("No scenes have chart data to generate previews.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGeneratingChartId("all");
|
||||
|
||||
const updatedScenes = await Promise.all(
|
||||
activeScript.scenes.map(async (scene) => {
|
||||
if (!scene.chart_data || Object.keys(scene.chart_data).length === 0) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await podcastApi.generateChartPreview({
|
||||
chart_data: scene.chart_data,
|
||||
chart_type: scene.chart_data.type || "bar_comparison",
|
||||
title: scene.title,
|
||||
});
|
||||
|
||||
return {
|
||||
...scene,
|
||||
broll_preview_url: result.preview_url,
|
||||
chart_id: result.chart_id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(`Failed to generate chart for scene ${scene.id}:`, err);
|
||||
return scene;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||
setScript(updatedScript);
|
||||
emitScriptChange(updatedScript);
|
||||
} catch (error: any) {
|
||||
console.error("Chart preview generation failed:", error);
|
||||
onError(`Failed to generate chart previews: ${error.message || error}`);
|
||||
} finally {
|
||||
setGeneratingChartId(null);
|
||||
}
|
||||
}, [activeScript, emitScriptChange, onError]);
|
||||
|
||||
const regenerateChart = useCallback(async (sceneId: string) => {
|
||||
if (!activeScript) return;
|
||||
|
||||
const scene = activeScript.scenes.find((s) => s.id === sceneId);
|
||||
if (!scene || !scene.chart_data) return;
|
||||
|
||||
try {
|
||||
setGeneratingChartId(sceneId);
|
||||
|
||||
const result = await podcastApi.generateChartPreview({
|
||||
chart_data: scene.chart_data,
|
||||
chart_type: scene.chart_data.type || "bar_comparison",
|
||||
title: scene.title,
|
||||
});
|
||||
|
||||
const updatedScenes = activeScript.scenes.map((s) =>
|
||||
s.id === sceneId
|
||||
? { ...s, broll_preview_url: result.preview_url, chart_id: result.chart_id }
|
||||
: s
|
||||
);
|
||||
|
||||
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||
setScript(updatedScript);
|
||||
emitScriptChange(updatedScript);
|
||||
} catch (error: any) {
|
||||
console.error("Chart regeneration failed:", error);
|
||||
onError(`Failed to regenerate chart: ${error.message || error}`);
|
||||
} finally {
|
||||
setGeneratingChartId(null);
|
||||
}
|
||||
}, [activeScript, emitScriptChange, onError]);
|
||||
|
||||
const removeChart = useCallback((sceneId: string) => {
|
||||
if (!activeScript) return;
|
||||
|
||||
const updatedScenes = activeScript.scenes.map((s) =>
|
||||
s.id === sceneId
|
||||
? { ...s, chart_data: undefined, broll_preview_url: undefined, broll_video_url: undefined }
|
||||
: s
|
||||
);
|
||||
|
||||
const updatedScript = { ...activeScript, scenes: updatedScenes };
|
||||
setScript(updatedScript);
|
||||
emitScriptChange(updatedScript);
|
||||
}, [activeScript, emitScriptChange]);
|
||||
|
||||
const value: ScriptEditorContextType = {
|
||||
// State
|
||||
script,
|
||||
loading,
|
||||
error,
|
||||
podcastMode,
|
||||
approvingSceneId,
|
||||
generatingAudioId,
|
||||
showScriptFormatInfo,
|
||||
combiningAudio,
|
||||
scriptTab,
|
||||
combinedAudioResult,
|
||||
generatingBatchAudio,
|
||||
batchAudioProgress,
|
||||
generatingChartId,
|
||||
|
||||
// Computed
|
||||
activeScript,
|
||||
allApproved,
|
||||
approvedCount,
|
||||
totalScenes,
|
||||
allScenesHaveAudio,
|
||||
scenesWithAudio,
|
||||
allScenesHaveAudioAndImages,
|
||||
needsAudioGeneration,
|
||||
scenesWithCharts,
|
||||
|
||||
// Setters for UI state
|
||||
setScript,
|
||||
setLoading,
|
||||
setError,
|
||||
setApprovingSceneId,
|
||||
setGeneratingAudioId,
|
||||
setShowScriptFormatInfo,
|
||||
setCombiningAudio,
|
||||
setScriptTab,
|
||||
setCombinedAudioResult,
|
||||
setGeneratingBatchAudio,
|
||||
setBatchAudioProgress,
|
||||
setGeneratingChartId,
|
||||
|
||||
// Actions
|
||||
updateScene,
|
||||
approveScene,
|
||||
deleteScene,
|
||||
generateAllAudio,
|
||||
combineAudio,
|
||||
emitScriptChange,
|
||||
// B-roll actions
|
||||
generateChartPreviews,
|
||||
regenerateChart,
|
||||
removeChart,
|
||||
};
|
||||
|
||||
return (
|
||||
<ScriptEditorContext.Provider value={value}>
|
||||
{children}
|
||||
</ScriptEditorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useScriptEditor = (): ScriptEditorContextType => {
|
||||
const context = useContext(ScriptEditorContext);
|
||||
if (!context) {
|
||||
throw new Error("useScriptEditor must be used within ScriptEditorProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Typography, Tabs, Tab, Chip } from "@mui/material";
|
||||
import { EditNote as EditNoteIcon, ArrowBack as ArrowBackIcon, AudioFile as AudioFileIcon, Videocam as VideocamIcon, Mic as MicIcon } from "@mui/icons-material";
|
||||
import { PodcastMode, Knobs } from "../types";
|
||||
import { SecondaryButton } from "../ui";
|
||||
import { useScriptEditor } from "./ScriptEditorContext";
|
||||
|
||||
interface ScriptEditorLayoutProps {
|
||||
onBackToResearch: () => void;
|
||||
knobs?: Knobs;
|
||||
}
|
||||
|
||||
// Helper function to get voice display name
|
||||
const getVoiceDisplayName = (voiceId?: string): string => {
|
||||
if (!voiceId) return "Default";
|
||||
if (voiceId === "Wise_Woman") return "Wise Woman";
|
||||
if (voiceId === "Friendly_Person") return "Friendly Person";
|
||||
if (voiceId === "Deep_Voice_Man") return "Deep Voice Man";
|
||||
if (voiceId === "Calm_Woman") return "Calm Woman";
|
||||
return voiceId.replace(/_/g, " ");
|
||||
};
|
||||
|
||||
export const ScriptEditorLayout: React.FC<ScriptEditorLayoutProps> = ({ onBackToResearch, knobs }) => {
|
||||
const { podcastMode, scriptTab, setScriptTab } = useScriptEditor();
|
||||
|
||||
const showTabs = podcastMode === "audio_video";
|
||||
const voiceId = knobs?.voice_id;
|
||||
const isCustomVoice = Boolean(voiceId && !voiceId.startsWith("builtin:") &&
|
||||
!["Wise_Woman", "Friendly_Person", "Inspirational_girl", "Deep_Voice_Man", "Calm_Woman",
|
||||
"Casual_Guy", "Lively_Girl", "Patient_Man", "Young_Knight", "Determined_Man",
|
||||
"Lovely_Girl", "Decent_Boy", "Imposing_Manner", "Elegant_Man", "Abbess",
|
||||
"Sweet_Girl_2", "Exuberant_Girl"].includes(voiceId));
|
||||
const voiceName = isCustomVoice ? "My Voice Clone" : getVoiceDisplayName(voiceId);
|
||||
|
||||
return (
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 4 }}>
|
||||
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
|
||||
Back to Research
|
||||
</SecondaryButton>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "-0.02em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
fontSize: { xs: "1.75rem", md: "2rem" },
|
||||
}}
|
||||
>
|
||||
<EditNoteIcon sx={{ fontSize: "2rem" }} />
|
||||
Script Editor
|
||||
{voiceId && (
|
||||
<Chip
|
||||
icon={<MicIcon sx={{ fontSize: "14px !important" }} />}
|
||||
label={`Active Voice: ${voiceName}`}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 2,
|
||||
background: isCustomVoice ? "rgba(16, 185, 129, 0.1)" : "rgba(99, 102, 241, 0.1)",
|
||||
color: isCustomVoice ? "#10b981" : "#6366f1",
|
||||
border: `1px solid ${isCustomVoice ? "rgba(16, 185, 129, 0.3)" : "rgba(99, 102, 241, 0.2)"}`,
|
||||
'& .MuiChip-icon': { color: isCustomVoice ? "#10b981" : "#6366f1" },
|
||||
fontWeight: 600,
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTabs && (
|
||||
<Tabs
|
||||
value={scriptTab}
|
||||
onChange={(_, v) => setScriptTab(v)}
|
||||
sx={{
|
||||
ml: 3,
|
||||
minHeight: 32,
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 32,
|
||||
py: 0.5,
|
||||
px: 2,
|
||||
fontSize: '0.8rem',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
value="audio"
|
||||
label="Audio Script"
|
||||
icon={<AudioFileIcon sx={{ fontSize: '1rem' }} />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
<Tab
|
||||
value="video"
|
||||
label="Video Script"
|
||||
icon={<VideocamIcon sx={{ fontSize: '1rem' }} />}
|
||||
iconPosition="start"
|
||||
/>
|
||||
</Tabs>
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5, ml: 5.5 }}>
|
||||
Review and refine your podcast script before rendering
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Stack,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Box,
|
||||
Divider,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Psychology as PsychologyIcon,
|
||||
Insights as InsightsIcon,
|
||||
Article as ArticleIcon,
|
||||
Edit as EditIcon,
|
||||
VolumeUp as VolumeUpIcon,
|
||||
VideoLibrary as VideoLibraryIcon,
|
||||
Lightbulb as LightbulbIcon,
|
||||
Search as SearchIcon,
|
||||
FactCheck as FactCheckIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { Research } from "../types";
|
||||
|
||||
const SCRIPT_GENERATION_MESSAGES = [
|
||||
{ title: "Processing Research", message: "Extracting key insights, statistics, and quotes from your research data..." },
|
||||
{ title: "Analyzing Your Topic", message: "Using your topic to shape the episode narrative and content structure..." },
|
||||
{ title: "Structuring Scenes", message: "Creating scene-by-scene breakdown based on research findings..." },
|
||||
{ title: "Writing Dialogue", message: "Generating natural conversation that flows from your insights..." },
|
||||
{ title: "Adding Transitions", message: "Creating smooth flow between scenes and topics..." },
|
||||
{ title: "Optimizing Pacing", message: "Ensuring engaging rhythm throughout the episode..." },
|
||||
{ title: "Final Review", message: "Validating script quality and preparing for editing..." },
|
||||
];
|
||||
|
||||
const RESEARCH_STATS_CONFIG = [
|
||||
{ label: "Key Insights", key: "keyInsights", icon: <InsightsIcon />, color: "#a78bfa" },
|
||||
{ label: "Fact Cards", key: "factCards", icon: <FactCheckIcon />, color: "#34d399" },
|
||||
{ label: "Angles", key: "mappedAngles", icon: <LightbulbIcon />, color: "#f59e0b" },
|
||||
{ label: "Sources", key: "sourceCount", icon: <SearchIcon />, color: "#60a5fa", isNumber: true },
|
||||
];
|
||||
|
||||
const PODCAST_CREATION_JOURNEY = [
|
||||
{
|
||||
phase: "Generate Script",
|
||||
icon: <AutoAwesomeIcon />,
|
||||
color: "#a78bfa",
|
||||
description: "AI transforms research into a structured podcast script",
|
||||
benefit: "Professional script based on your research insights"
|
||||
},
|
||||
{
|
||||
phase: "Edit Scenes",
|
||||
icon: <EditIcon />,
|
||||
color: "#34d399",
|
||||
description: "Review and refine each scene in the Script Editor",
|
||||
benefit: "Full control over your content"
|
||||
},
|
||||
{
|
||||
phase: "Approve Content",
|
||||
icon: <CheckCircleIcon />,
|
||||
color: "#10b981",
|
||||
description: "Mark scenes as approved before audio generation",
|
||||
benefit: "Ensures content isexactly as you want"
|
||||
},
|
||||
{
|
||||
phase: "Generate Audio",
|
||||
icon: <VolumeUpIcon />,
|
||||
color: "#f59e0b",
|
||||
description: "Convert script to natural-sounding podcast audio",
|
||||
benefit: "Ready-to-use audio narration"
|
||||
},
|
||||
{
|
||||
phase: "Final Render",
|
||||
icon: <VideoLibraryIcon />,
|
||||
color: "#ef4444",
|
||||
description: "Combine into your final podcast episode",
|
||||
benefit: "Download or share your episode"
|
||||
},
|
||||
];
|
||||
|
||||
interface ScriptProgressViewProps {
|
||||
currentMessage?: string;
|
||||
progressIndex: number;
|
||||
research?: Research | null;
|
||||
idea?: string;
|
||||
}
|
||||
|
||||
export const ScriptProgressView: React.FC<ScriptProgressViewProps> = ({
|
||||
currentMessage,
|
||||
progressIndex,
|
||||
research,
|
||||
idea,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const clampedIndex = Math.min(progressIndex, SCRIPT_GENERATION_MESSAGES.length - 1);
|
||||
|
||||
const getResearchValue = (key: string, isNumber?: boolean) => {
|
||||
if (!research) return 0;
|
||||
const value = (research as any)[key];
|
||||
if (isNumber) return research.sourceCount || 0;
|
||||
return Array.isArray(value) ? value.length : value || 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{/* Current Status */}
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<Box sx={{ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<CircularProgress size={isMobile ? 50 : 60} thickness={3} sx={{ color: "#a78bfa" }} />
|
||||
<Box sx={{ position: "absolute", display: "flex", flexDirection: "column", alignItems: "center" }}>
|
||||
<AutoAwesomeIcon sx={{ color: "#a78bfa", fontSize: isMobile ? 20 : 24 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography variant="subtitle1" sx={{ color: "#a78bfa", fontWeight: 600, mt: 1, fontSize: isMobile ? "0.85rem" : "0.95rem" }}>
|
||||
{SCRIPT_GENERATION_MESSAGES[clampedIndex].title}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" sx={{ color: "rgba(255,255,255,0.7)", mt: 0.5, fontSize: isMobile ? "0.75rem" : "0.85rem", px: 1 }}>
|
||||
{currentMessage || SCRIPT_GENERATION_MESSAGES[clampedIndex].message}
|
||||
</Typography>
|
||||
|
||||
{currentMessage && (
|
||||
<Typography variant="caption" sx={{ color: "#10b981", mt: 0.5, display: "block", fontSize: "0.75rem" }}>
|
||||
{currentMessage}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<LinearProgress
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
bgcolor: "rgba(255,255,255,0.1)",
|
||||
mt: 2,
|
||||
"& .MuiLinearProgress-bar": { bgcolor: "#a78bfa", borderRadius: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.4)", mt: 0.5, display: "block" }}>
|
||||
Step {clampedIndex + 1} of {SCRIPT_GENERATION_MESSAGES.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||
|
||||
{/* Research Stats */}
|
||||
{research && (
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||
Using Your Research
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{RESEARCH_STATS_CONFIG.map((stat, idx) => {
|
||||
const value = getResearchValue(stat.key, stat.isNumber);
|
||||
return (
|
||||
<Box key={idx} sx={{
|
||||
flex: "1 1 auto",
|
||||
minWidth: 80,
|
||||
p: 1,
|
||||
borderRadius: 1.5,
|
||||
bgcolor: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<Box sx={{ color: stat.color, mb: 0.25 }}>{stat.icon}</Box>
|
||||
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 700, fontSize: "1rem" }}>
|
||||
{value}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.65rem" }}>
|
||||
{stat.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||
|
||||
{/* Sequential Progress Steps */}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||
Script Generation Progress
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{SCRIPT_GENERATION_MESSAGES.map((msg, idx) => {
|
||||
const isCompleted = idx < clampedIndex;
|
||||
const isCurrent = idx === clampedIndex;
|
||||
return (
|
||||
<Stack key={idx} direction="row" spacing={1} alignItems="flex-start">
|
||||
<Box sx={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
bgcolor: isCompleted ? "#10b981" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.1)",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isCompleted ? (
|
||||
<CheckCircleIcon sx={{ fontSize: 12, color: "#fff" }} />
|
||||
) : isCurrent ? (
|
||||
<CircularProgress size={10} sx={{ color: "#fff" }} />
|
||||
) : (
|
||||
<Box sx={{ width: 4, height: 4, borderRadius: "50%", bgcolor: "rgba(255,255,255,0.3)" }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" sx={{
|
||||
color: isCompleted ? "rgba(255,255,255,0.5)" : isCurrent ? "#a78bfa" : "rgba(255,255,255,0.6)",
|
||||
fontWeight: isCurrent ? 600 : 400,
|
||||
fontSize: "0.75rem",
|
||||
textDecoration: isCompleted ? "line-through" : "none",
|
||||
}}>
|
||||
{msg.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: "rgba(255,255,255,0.15)" }} />
|
||||
|
||||
{/* Journey Overview */}
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.65rem", mb: 1, display: "block" }}>
|
||||
Your Podcast Journey
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{PODCAST_CREATION_JOURNEY.map((phase, idx) => (
|
||||
<Box key={idx} sx={{ p: 1.5, borderRadius: 2, bgcolor: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<Box sx={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
bgcolor: `${phase.color}20`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{React.cloneElement(phase.icon, { sx: { color: phase.color, fontSize: 16 } })}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: "#fff", fontWeight: 600, fontSize: "0.8rem" }}>
|
||||
{phase.phase}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "rgba(255,255,255,0.6)", fontSize: "0.7rem", display: "block" }}>
|
||||
{phase.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: phase.color, fontSize: "0.65rem", display: "block", mt: 0.25 }}>
|
||||
✓ {phase.benefit}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Alert, Paper, Button, CircularProgress, Chip } from "@mui/material";
|
||||
import { BarChart as BarChartIcon, AutoAwesome as AutoAwesomeIcon, Refresh as RefreshIcon, DeleteOutline as DeleteIcon } from "@mui/icons-material";
|
||||
import { useScriptEditor } from "../ScriptEditorContext";
|
||||
|
||||
export const BrollInfoPanel: React.FC = () => {
|
||||
const {
|
||||
activeScript,
|
||||
generatingChartId,
|
||||
setGeneratingChartId,
|
||||
generateChartPreviews,
|
||||
regenerateChart,
|
||||
removeChart,
|
||||
scenesWithCharts
|
||||
} = useScriptEditor();
|
||||
|
||||
if (!activeScript || activeScript.scenes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenesWithData = activeScript.scenes.filter(s => s.chart_data && Object.keys(s.chart_data).length > 0);
|
||||
const hasChartData = scenesWithData.length > 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(34, 197, 94, 0.05) 0%, rgba(16, 185, 129, 0.05) 100%)",
|
||||
border: "1px solid rgba(34, 197, 94, 0.15)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 2 }}>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<BarChartIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
B-Roll Charts
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||
Programmatic charts extracted from research data
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hasChartData && (
|
||||
<Chip
|
||||
label={`${scenesWithData.length} scene${scenesWithData.length > 1 ? 's' : ''} with charts`}
|
||||
size="small"
|
||||
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a", fontWeight: 600 }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{!hasChartData ? (
|
||||
<Alert severity="info" sx={{ background: "rgba(34, 197, 94, 0.06)", border: "1px solid rgba(34, 197, 94, 0.15)", "& .MuiAlert-icon": { color: "#22c55e" } }}>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||
<strong style={{ fontWeight: 600 }}>No charts detected.</strong> If your research contains statistics or metrics, the script generation will automatically extract chart data for B-roll visualization.
|
||||
</Typography>
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>
|
||||
Your script contains <strong style={{ fontWeight: 600 }}>{scenesWithData.length}</strong> scene(s) with chart data.
|
||||
Click below to generate chart previews for the Write phase.
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={generatingChartId ? <CircularProgress size={16} color="inherit" /> : <AutoAwesomeIcon />}
|
||||
onClick={generateChartPreviews}
|
||||
disabled={!!generatingChartId}
|
||||
sx={{
|
||||
background: "linear-gradient(135deg, #22c55e 0%, #10b981 100%)",
|
||||
"&:hover": { background: "linear-gradient(135deg, #16a34a 0%, #059669 100%)" },
|
||||
textTransform: "none",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{generatingChartId ? "Generating..." : "Generate Chart Previews"}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{scenesWithData.map((scene) => (
|
||||
<Box
|
||||
key={scene.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
background: "rgba(0,0,0,0.02)",
|
||||
borderRadius: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{scene.title}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: "#64748b" }}>
|
||||
{scene.chart_data?.type || "chart"} • {scene.chart_data?.labels?.length || 0} data points
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
{generatingChartId === scene.id ? (
|
||||
<CircularProgress size={20} />
|
||||
) : scene.broll_preview_url ? (
|
||||
<>
|
||||
<Chip
|
||||
label="Preview Ready"
|
||||
size="small"
|
||||
sx={{ background: "rgba(34, 197, 94, 0.1)", color: "#16a34a" }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => regenerateChart(scene.id)}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => removeChart(scene.id)}
|
||||
sx={{ color: "#ef4444" }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
|
||||
import { CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon } from "@mui/icons-material";
|
||||
import { Script } from "../../types";
|
||||
import { useScriptEditor } from "../ScriptEditorContext";
|
||||
import { PrimaryButton } from "../../ui";
|
||||
|
||||
interface ScriptEditorApprovalPanelProps {
|
||||
onProceedToRendering: (script: Script) => void;
|
||||
}
|
||||
|
||||
export const ScriptEditorApprovalPanel: React.FC<ScriptEditorApprovalPanelProps> = ({ onProceedToRendering }) => {
|
||||
const { activeScript, allApproved, approvedCount, totalScenes, allScenesHaveAudioAndImages } = useScriptEditor();
|
||||
const approved = allApproved ?? false;
|
||||
const ready = allScenesHaveAudioAndImages ?? false;
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3.5, background: approved ? "linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%)" : "#ffffff", border: approved ? "2px solid rgba(16, 185, 129, 0.25)" : "1px solid rgba(15, 23, 42, 0.08)", borderRadius: 3 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ mb: 1, display: "flex", alignItems: "center", gap: 1.5, color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
<CheckCircleIcon fontSize="small" sx={{ color: approved ? "#10b981" : "#94a3b8", fontSize: "1.25rem" }} />Approval Status
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", fontWeight: 400, lineHeight: 1.6 }}>
|
||||
{approvedCount} of {totalScenes} scenes approved{!approved && " — Approve all scenes first"}
|
||||
</Typography>
|
||||
{!ready && <LinearProgress variant="determinate" value={ready ? 100 : (activeScript ? (activeScript.scenes.filter((s) => s.audioUrl && s.imageUrl).length / totalScenes) * 100 : 0)} sx={{ mt: 1, height: 6, borderRadius: 3 }} />}
|
||||
</Box>
|
||||
<PrimaryButton onClick={() => activeScript && onProceedToRendering(activeScript)} disabled={!ready} startIcon={<PlayArrowIcon />}>
|
||||
Proceed to Rendering
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Paper, LinearProgress } from "@mui/material";
|
||||
import { AudioFile as AudioFileIcon } from "@mui/icons-material";
|
||||
import { useScriptEditor } from "../ScriptEditorContext";
|
||||
import { PrimaryButton } from "../../ui";
|
||||
|
||||
export const ScriptEditorAudioPanel: React.FC = () => {
|
||||
const { activeScript, needsAudioGeneration, generatingBatchAudio, batchAudioProgress, generateAllAudio } = useScriptEditor();
|
||||
|
||||
if (!(needsAudioGeneration ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
background: "linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.08) 100%)",
|
||||
border: "1px solid rgba(16, 185, 129, 0.2)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ color: "#059669", fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<AudioFileIcon /> Generate All Audio
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.5 }}>
|
||||
{activeScript && `${activeScript.scenes.filter(s => !s.audioUrl).length} scenes need audio`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<PrimaryButton
|
||||
onClick={generateAllAudio}
|
||||
disabled={generatingBatchAudio}
|
||||
loading={generatingBatchAudio}
|
||||
startIcon={<AudioFileIcon />}
|
||||
sx={{ background: "linear-gradient(135deg, #10b981 0%, #059669 100%)" }}
|
||||
>
|
||||
{generatingBatchAudio
|
||||
? (batchAudioProgress ? `Generating ${batchAudioProgress.completed}/${batchAudioProgress.total}...` : "Generating...")
|
||||
: "Generate All Audio"}
|
||||
</PrimaryButton>
|
||||
</Stack>
|
||||
{(batchAudioProgress !== null && batchAudioProgress !== undefined) && (
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(batchAudioProgress.completed / batchAudioProgress.total) * 100}
|
||||
sx={{ mt: 2, height: 8, borderRadius: 4 }}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from "react";
|
||||
import { Stack, Typography, Paper, Alert, alpha } from "@mui/material";
|
||||
import { Download as DownloadIcon, Refresh as RefreshIcon } from "@mui/icons-material";
|
||||
import { useScriptEditor } from "../ScriptEditorContext";
|
||||
import { PrimaryButton, SecondaryButton } from "../../ui";
|
||||
import { InlineAudioPlayer } from "../../InlineAudioPlayer";
|
||||
import { aiApiClient } from "../../../../api/client";
|
||||
|
||||
interface ScriptEditorDownloadPanelProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export const ScriptEditorDownloadPanel: React.FC<ScriptEditorDownloadPanelProps> = ({ projectId }) => {
|
||||
const { allScenesHaveAudio, scenesWithAudio, combiningAudio, combinedAudioResult, combineAudio, setCombinedAudioResult } = useScriptEditor();
|
||||
|
||||
if (!(allScenesHaveAudio ?? false)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDownloadAgain = async () => {
|
||||
if (!combinedAudioResult) return;
|
||||
try {
|
||||
let audioPath = combinedAudioResult.url.startsWith('/') ? combinedAudioResult.url : `/${combinedAudioResult.url}`;
|
||||
if (!audioPath.includes('/api/podcast/audio/')) {
|
||||
const filename = audioPath.split('/').pop() || combinedAudioResult.filename;
|
||||
audioPath = `/api/podcast/audio/${filename}`;
|
||||
}
|
||||
audioPath = audioPath.split('?')[0];
|
||||
const response = await aiApiClient.get(audioPath, { responseType: 'blob' });
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = combinedAudioResult.filename || `podcast-episode-${projectId.slice(-8)}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
} catch (error) {
|
||||
console.error('Failed to download audio:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, background: "linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)", border: "1px solid rgba(102, 126, 234, 0.15)", borderRadius: 2 }}>
|
||||
<Stack spacing={3}>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600 }}>Download Audio-Only Podcast</Typography>
|
||||
{!combinedAudioResult ? (
|
||||
<>
|
||||
<PrimaryButton onClick={combineAudio} disabled={combiningAudio} loading={combiningAudio} startIcon={<DownloadIcon />} sx={{ minWidth: 280, fontSize: "1rem", py: 1.5, background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" }}>
|
||||
{combiningAudio ? "Combining Audio..." : "Download Audio-Only Podcast"}
|
||||
</PrimaryButton>
|
||||
<Typography variant="caption" sx={{ color: "#64748b", fontStyle: "italic" }}>This will combine all {scenesWithAudio} scene audio files into one complete podcast episode.</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Stack spacing={2}>
|
||||
<Alert severity="success" sx={{ background: alpha("#10b981", 0.1), border: "1px solid rgba(16,185,129,0.3)", "& .MuiAlert-icon": { color: "#10b981" } }}>
|
||||
<Typography variant="body2" sx={{ color: "#059669", fontWeight: 500 }}>✅ Combined audio generated successfully! ({combinedAudioResult.sceneCount} scenes, {Math.round(combinedAudioResult.duration)}s)</Typography>
|
||||
</Alert>
|
||||
<InlineAudioPlayer audioUrl={combinedAudioResult.url} title="Complete Podcast Episode" />
|
||||
<Stack direction="row" spacing={2}>
|
||||
<SecondaryButton onClick={handleDownloadAgain} startIcon={<DownloadIcon />}>Download Again</SecondaryButton>
|
||||
<SecondaryButton onClick={() => { setCombinedAudioResult(null); combineAudio(); }} disabled={combiningAudio} loading={combiningAudio} startIcon={<RefreshIcon />}>Regenerate</SecondaryButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { Stack, Box, Typography, Alert, Paper } from "@mui/material";
|
||||
import { Info as InfoIcon, ExpandMore as ExpandMoreIcon, ExpandLess as ExpandLessIcon } from "@mui/icons-material";
|
||||
import { useScriptEditor } from "../ScriptEditorContext";
|
||||
|
||||
interface FormatItem {
|
||||
num: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
const formatItems: FormatItem[] = [
|
||||
{ num: "1", title: "Natural Pauses & Rhythm", desc: "The script includes strategic pauses between lines and when speakers change. This creates natural breathing patterns and conversation flow." },
|
||||
{ num: "2", title: "Emphasis Markers", desc: "Lines marked with emphasis help highlight important points, statistics, or key insights." },
|
||||
{ num: "3", title: "Short, Conversational Sentences", desc: "The script uses shorter sentences written in a conversational style that matches how people actually speak." },
|
||||
{ num: "4", title: "Scene-Specific Emotions", desc: "Each scene has an emotional tone that guides the AI voice's delivery." },
|
||||
{ num: "5", title: "Optimized for Podcast Narration", desc: "The script is optimized with slightly slower pacing and natural pronunciation settings." },
|
||||
];
|
||||
|
||||
export const ScriptEditorInfoPanel: React.FC = () => {
|
||||
const { showScriptFormatInfo, setShowScriptFormatInfo } = useScriptEditor();
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 3,
|
||||
background: "linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)",
|
||||
border: "1px solid rgba(99, 102, 241, 0.15)",
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: showScriptFormatInfo ? 2 : 0 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.5}>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: "50%", background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<InfoIcon sx={{ color: "#ffffff", fontSize: "1.5rem" }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: "#0f172a", fontWeight: 600, fontSize: "1.1rem" }}>
|
||||
Why This Script Format?
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#64748b", mt: 0.25 }}>
|
||||
Understanding how your script creates natural, human-like audio
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
color: "#6366f1",
|
||||
cursor: "pointer",
|
||||
p: 0.5,
|
||||
borderRadius: 1,
|
||||
"&:hover": { background: "rgba(99, 102, 241, 0.1)" },
|
||||
}}
|
||||
onClick={() => setShowScriptFormatInfo(!showScriptFormatInfo)}
|
||||
>
|
||||
{showScriptFormatInfo ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{showScriptFormatInfo && (
|
||||
<Stack spacing={2.5}>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.8 }}>
|
||||
Our AI script generator creates scripts specifically optimized for <strong style={{ fontWeight: 600 }}>high-quality text-to-speech</strong>. The format you see here is designed to produce audio that sounds natural and human-like, not robotic.
|
||||
</Typography>
|
||||
<Stack spacing={2}>
|
||||
{formatItems.map((item) => (
|
||||
<Box key={item.num} sx={{ display: "flex", gap: 2 }}>
|
||||
<Box sx={{ minWidth: 32, height: 32, borderRadius: "8px", background: "linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<Typography variant="body2" sx={{ color: "#6366f1", fontWeight: 700 }}>{item.num}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ color: "#0f172a", fontWeight: 600, mb: 0.5 }}>{item.title}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#475569", lineHeight: 1.7 }}>{item.desc}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Alert severity="info" sx={{ background: "rgba(99, 102, 241, 0.06)", border: "1px solid rgba(99, 102, 241, 0.15)", "& .MuiAlert-icon": { color: "#6366f1" } }}>
|
||||
<Typography variant="body2" sx={{ color: "#0f172a", lineHeight: 1.7 }}>
|
||||
<strong style={{ fontWeight: 600 }}>Tip:</strong> You can edit any line or scene to match your preferences. The format will be preserved when rendering.
|
||||
</Typography>
|
||||
</Alert>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user