559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
|
import { Script, Knobs, Scene, PodcastMode } from "../types";
|
|
import { podcastApi } from "../../../services/podcastApi";
|
|
import { getApiUrl } from "../../../api/client";
|
|
|
|
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);
|
|
|
|
const toUsablePreviewUrl = (previewUrl?: string): string | undefined => {
|
|
if (!previewUrl) return undefined;
|
|
if (/^https?:\/\//i.test(previewUrl)) return previewUrl;
|
|
const cleanPath = previewUrl.startsWith("/") ? previewUrl : `/${previewUrl}`;
|
|
return `${getApiUrl()}${cleanPath}`;
|
|
};
|
|
|
|
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: toUsablePreviewUrl(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: toUsablePreviewUrl(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;
|
|
};
|