WIP: AI Podcast Maker and YouTube Creator Studio integration

This commit is contained in:
ajaysi
2025-12-10 09:37:55 +05:30
parent 31f078c763
commit 81590cf4db
75 changed files with 11879 additions and 1380 deletions

View File

@@ -0,0 +1,106 @@
import React, { useEffect, useState } from "react";
import { Stack, Box, Typography, TextField, Button, Chip, CircularProgress, alpha } from "@mui/material";
import { VolumeUp as VolumeUpIcon } from "@mui/icons-material";
import { Line } from "../types";
import { GlassyCard, glassyCardSx } from "../ui";
interface LineEditorProps {
line: Line;
onChange: (l: Line) => void;
onPreview: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
}
export const LineEditor: React.FC<LineEditorProps> = ({ line, onChange, onPreview }) => {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(line.text);
const [previewing, setPreviewing] = useState(false);
useEffect(() => setText(line.text), [line.text]);
const handleSave = () => {
onChange({ ...line, text });
setEditing(false);
};
const handlePreview = async () => {
setPreviewing(true);
try {
const res = await onPreview(text);
if (res.audioUrl) {
window.open(res.audioUrl, "_blank");
} else {
alert(res.message);
}
} finally {
setPreviewing(false);
}
};
return (
<GlassyCard
whileHover={{ y: -2 }}
sx={{
...glassyCardSx,
p: 2,
transition: "all 0.2s",
}}
>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box flex={1}>
<Chip label={line.speaker} size="small" sx={{ mb: 1, background: alpha("#667eea", 0.2), color: "#a78bfa" }} />
{editing ? (
<TextField
fullWidth
multiline
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
color: "white",
"& fieldset": { borderColor: "rgba(255,255,255,0.2)" },
},
}}
/>
) : (
<Typography variant="body2" sx={{ lineHeight: 1.7, color: "rgba(255,255,255,0.9)" }}>
{line.text}
</Typography>
)}
{line.usedFactIds && line.usedFactIds.length > 0 && (
<Stack direction="row" spacing={0.5} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
<Typography variant="caption" color="text.secondary">
Facts:
</Typography>
{line.usedFactIds.map((id) => (
<Chip key={id} label={id} size="small" variant="outlined" sx={{ fontSize: "0.65rem", height: 20 }} />
))}
</Stack>
)}
</Box>
<Stack spacing={1} sx={{ ml: 2 }}>
<Button
size="small"
variant={editing ? "contained" : "outlined"}
onClick={editing ? handleSave : () => setEditing(true)}
sx={{ minWidth: 80 }}
>
{editing ? "Save" : "Edit"}
</Button>
<Button
size="small"
variant="outlined"
startIcon={previewing ? <CircularProgress size={14} /> : <VolumeUpIcon />}
onClick={handlePreview}
disabled={previewing || editing}
sx={{ minWidth: 120 }}
>
Preview TTS
</Button>
</Stack>
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,86 @@
import React from "react";
import { Stack, Box, Typography, Divider, Chip, alpha } from "@mui/material";
import {
EditNote as EditNoteIcon,
CheckCircle as CheckCircleIcon,
RadioButtonUnchecked as RadioButtonUncheckedIcon,
} from "@mui/icons-material";
import { Scene, Line } from "../types";
import { GlassyCard, glassyCardSx, PrimaryButton } from "../ui";
import { LineEditor } from "./LineEditor";
interface SceneEditorProps {
scene: Scene;
onUpdateScene: (s: Scene) => void;
onApprove: (id: string) => Promise<void>;
onPreviewLine: (text: string) => Promise<{ ok: boolean; message: string; audioUrl?: string }>;
approvingSceneId?: string | null;
}
export const SceneEditor: React.FC<SceneEditorProps> = ({
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;
const handleApprove = async () => {
await onApprove(scene.id);
onUpdateScene({ ...scene, approved: true });
};
return (
<GlassyCard sx={glassyCardSx}>
<Stack spacing={2}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Box>
<Typography variant="h6" sx={{ display: "flex", alignItems: "center", gap: 1, mb: 0.5 }}>
<EditNoteIcon fontSize="small" />
{scene.title}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<Chip
icon={scene.approved ? <CheckCircleIcon /> : <RadioButtonUncheckedIcon />}
label={scene.approved ? "Approved" : "Pending Approval"}
size="small"
color={scene.approved ? "success" : "warning"}
sx={{
background: scene.approved ? alpha("#10b981", 0.2) : alpha("#f59e0b", 0.2),
color: scene.approved ? "#6ee7b7" : "#fbbf24",
border: scene.approved ? "1px solid rgba(16,185,129,0.3)" : "1px solid rgba(245,158,11,0.3)",
}}
/>
<Typography variant="caption" color="text.secondary">
Duration: {scene.duration}s
</Typography>
</Stack>
</Box>
<PrimaryButton
onClick={handleApprove}
disabled={scene.approved || approving}
loading={approving}
startIcon={scene.approved ? <CheckCircleIcon /> : undefined}
tooltip={scene.approved ? "Scene is approved and ready for rendering" : "Approve this scene to enable rendering"}
>
{scene.approved ? "Approved" : approving ? "Approving..." : "Approve Scene"}
</PrimaryButton>
</Stack>
<Divider sx={{ borderColor: "rgba(255,255,255,0.1)" }} />
<Stack spacing={2}>
{scene.lines.map((line) => (
<LineEditor key={line.id} line={line} onChange={updateLine} onPreview={(text) => onPreviewLine(text)} />
))}
</Stack>
</Stack>
</GlassyCard>
);
};

View File

@@ -0,0 +1,229 @@
import React, { useEffect, useState } from "react";
import { Box, Stack, Typography, Alert, Paper, LinearProgress, CircularProgress, alpha } from "@mui/material";
import { EditNote as EditNoteIcon, CheckCircle as CheckCircleIcon, PlayArrow as PlayArrowIcon, ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import { Script, Knobs, Scene } from "../types";
import { BlogResearchResponse } from "../../../services/blogWriterApi";
import { podcastApi } from "../../../services/podcastApi";
import { GlassyCard, PrimaryButton, SecondaryButton } from "../ui";
import { SceneEditor } from "./SceneEditor";
interface ScriptEditorProps {
projectId: string;
idea: string;
research: any; // Research type
rawResearch: BlogResearchResponse | null;
knobs: Knobs;
speakers: number;
durationMinutes: number;
script: Script | null;
onScriptChange: (script: Script) => void;
onBackToResearch: () => void;
onProceedToRendering: (script: Script) => void;
onError: (message: string) => void;
}
export const ScriptEditor: React.FC<ScriptEditorProps> = ({
projectId,
idea,
research,
rawResearch,
knobs,
speakers,
durationMinutes,
script: initialScript,
onScriptChange,
onBackToResearch,
onProceedToRendering,
onError,
}) => {
const [script, setScript] = useState<Script | null>(initialScript);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [approvingSceneId, setApprovingSceneId] = useState<string | null>(null);
// Sync with parent state
useEffect(() => {
if (initialScript) {
setScript(initialScript);
}
}, [initialScript]);
useEffect(() => {
// If script already exists, don't regenerate
if (script) {
return;
}
// Only generate if we have research data
if (!rawResearch) {
return;
}
let mounted = true;
setLoading(true);
setError(null);
podcastApi
.generateScript({
projectId,
idea,
research: rawResearch,
knobs,
speakers,
durationMinutes,
})
.then((res) => {
if (mounted) {
setScript(res);
onScriptChange(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]);
const updateScene = (updated: Scene) => {
if (!script) return;
const updatedScript = { ...script, scenes: script.scenes.map((s) => (s.id === updated.id ? updated : s)) };
setScript(updatedScript);
onScriptChange(updatedScript);
};
const approveScene = async (sceneId: string) => {
try {
setApprovingSceneId(sceneId);
await podcastApi.approveScene({ projectId, sceneId });
const updatedScript = script
? {
...script,
scenes: script.scenes.map((s) => (s.id === sceneId ? { ...s, approved: true } : s)),
}
: null;
if (updatedScript) {
setScript(updatedScript);
onScriptChange(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 allApproved = script && script.scenes.every((s) => s.approved);
const approvedCount = script ? script.scenes.filter((s) => s.approved).length : 0;
const totalScenes = script ? script.scenes.length : 0;
return (
<Box sx={{ mt: 3 }}>
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 3 }}>
<SecondaryButton onClick={onBackToResearch} startIcon={<ArrowBackIcon />}>
Back to Research
</SecondaryButton>
<Typography
variant="h4"
sx={{
background: "linear-gradient(135deg, #a78bfa 0%, #60a5fa 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: 800,
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<EditNoteIcon />
Script Editor
</Typography>
</Stack>
{loading && (
<Alert severity="info" icon={<CircularProgress size={20} />} sx={{ mb: 3 }}>
<Typography variant="body2">Generating script with AI... This may take a moment.</Typography>
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{script && (
<Stack spacing={3}>
<Alert severity="info" sx={{ background: alpha("#3b82f6", 0.1), border: "1px solid rgba(59,130,246,0.3)" }}>
<Typography variant="body2">
<strong>Approval Required:</strong> Each scene must be approved before rendering. Review and edit lines as needed, then approve each scene.
</Typography>
</Alert>
<Stack spacing={2}>
{script.scenes.map((scene, idx) => (
<GlassyCard
key={scene.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: idx * 0.1 }}
>
<SceneEditor
scene={scene}
onUpdateScene={updateScene}
onApprove={approveScene}
onPreviewLine={(text) => podcastApi.previewLine(text)}
approvingSceneId={approvingSceneId}
/>
</GlassyCard>
))}
</Stack>
<Paper
sx={{
p: 3,
background: alpha("#1e293b", 0.6),
border: allApproved ? "2px solid rgba(16,185,129,0.4)" : "1px solid rgba(255,255,255,0.1)",
}}
>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5, display: "flex", alignItems: "center", gap: 1 }}>
<CheckCircleIcon fontSize="small" color={allApproved ? "success" : "disabled"} />
Approval Status
</Typography>
<Typography variant="body2" color="text.secondary">
{approvedCount} of {totalScenes} scenes approved
{!allApproved && " — Approve all scenes to enable rendering"}
</Typography>
{!allApproved && (
<LinearProgress
variant="determinate"
value={(approvedCount / totalScenes) * 100}
sx={{ mt: 1, height: 6, borderRadius: 3 }}
/>
)}
</Box>
<PrimaryButton
onClick={() => script && onProceedToRendering(script)}
disabled={!allApproved}
startIcon={<PlayArrowIcon />}
tooltip={!allApproved ? "Approve all scenes to proceed to rendering" : "Start rendering all approved scenes"}
>
Proceed to Rendering
</PrimaryButton>
</Stack>
</Paper>
</Stack>
)}
</Box>
);
};

View File

@@ -0,0 +1,4 @@
export { LineEditor } from "./LineEditor";
export { SceneEditor } from "./SceneEditor";
export { ScriptEditor } from "./ScriptEditor";