AI story writer enhancements, text to video and voice generation, subscription management, and more.
This commit is contained in:
@@ -95,25 +95,38 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
|
||||
try {
|
||||
// Prepare image and audio URLs in scene order
|
||||
const imageUrls: string[] = [];
|
||||
const imageUrls: (string | null)[] = [];
|
||||
const audioUrls: string[] = [];
|
||||
const scenes = state.outlineScenes;
|
||||
|
||||
const videoUrls: (string | null)[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
|
||||
const imageUrl = state.sceneImages?.get(sceneNumber);
|
||||
const audioUrl = state.sceneAudio?.get(sceneNumber);
|
||||
const animatedVideoUrl = state.sceneAnimatedVideos?.get(sceneNumber);
|
||||
|
||||
if (imageUrl && audioUrl) {
|
||||
imageUrls.push(imageUrl);
|
||||
audioUrls.push(audioUrl);
|
||||
} else {
|
||||
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
|
||||
if (!audioUrl) {
|
||||
throw new Error(`Missing audio for scene ${sceneNumber}`);
|
||||
}
|
||||
|
||||
// Prefer animated video if available, otherwise use image
|
||||
if (animatedVideoUrl) {
|
||||
videoUrls.push(animatedVideoUrl);
|
||||
imageUrls.push(null);
|
||||
} else if (imageUrl) {
|
||||
videoUrls.push(null);
|
||||
imageUrls.push(imageUrl);
|
||||
} else {
|
||||
throw new Error(`Missing image or animated video for scene ${sceneNumber}`);
|
||||
}
|
||||
|
||||
audioUrls.push(audioUrl);
|
||||
}
|
||||
|
||||
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
|
||||
throw new Error('Number of images and audio files must match number of scenes');
|
||||
throw new Error('Number of images/videos and audio files must match number of scenes');
|
||||
}
|
||||
|
||||
// Start async video generation
|
||||
@@ -121,6 +134,8 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
scenes: scenes,
|
||||
image_urls: imageUrls,
|
||||
audio_urls: audioUrls,
|
||||
video_urls: videoUrls.length > 0 ? videoUrls : undefined,
|
||||
ai_audio_urls: undefined, // TODO: Track AI audio separately in state
|
||||
story_title: state.storySetting || 'Story',
|
||||
fps: state.videoFps,
|
||||
transition_duration: state.videoTransitionDuration,
|
||||
@@ -147,7 +162,11 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
state.setStoryVideo(videoUrl);
|
||||
// fetch blob for authenticated preview
|
||||
const blobUrl = await fetchMediaBlobUrl(videoUrl);
|
||||
setVideoBlobUrl(blobUrl);
|
||||
if (blobUrl) {
|
||||
setVideoBlobUrl(blobUrl);
|
||||
} else {
|
||||
setVideoBlobUrl(null);
|
||||
}
|
||||
setVideoProgress(100);
|
||||
setVideoMessage('Video generation complete');
|
||||
state.setError(null);
|
||||
@@ -175,6 +194,9 @@ const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
|
||||
const handleDownloadVideo = async () => {
|
||||
if (state.storyVideo) {
|
||||
const blobUrl = await fetchMediaBlobUrl(state.storyVideo);
|
||||
if (!blobUrl) {
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `story-video-${Date.now()}.mp4`;
|
||||
|
||||
@@ -14,9 +14,9 @@ import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { useStoryWriterState, SceneAnimationResume } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { aiApiClient } from '../../../api/client';
|
||||
import { aiApiClient, triggerSubscriptionError } from '../../../api/client';
|
||||
import OutlineHoverActions from './StoryOutlineParts/OutlineHoverActions';
|
||||
import EditSectionModal from './StoryOutlineParts/EditSectionModal';
|
||||
import { leftPageVariants, rightPageVariants } from './StoryOutlineParts/pageVariants';
|
||||
@@ -48,7 +48,9 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [audioBlobUrls, setAudioBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [videoBlobUrls, setVideoBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [audioLoadError, setAudioLoadError] = useState<Set<number>>(new Set());
|
||||
const [hasVideoLoadError, setVideoLoadError] = useState<Set<number>>(new Set());
|
||||
const [outlineToastOpen, setOutlineToastOpen] = useState(false);
|
||||
const lastToastSceneCount = useRef<number | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
@@ -66,15 +68,182 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
const [isKeyEventsModalOpen, setIsKeyEventsModalOpen] = useState(false);
|
||||
const [isTitleModalOpen, setIsTitleModalOpen] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState('');
|
||||
const [animatingSceneNumber, setAnimatingSceneNumber] = useState<number | null>(null);
|
||||
|
||||
// Use state from hook instead of local state
|
||||
const sceneImages = state.sceneImages || new Map<number, string>();
|
||||
const sceneAudio = state.sceneAudio || new Map<number, string>();
|
||||
const sceneAnimatedVideos = state.sceneAnimatedVideos || new Map<number, string>();
|
||||
const sceneAnimationResumables = state.sceneAnimationResumables || new Map<number, SceneAnimationResume>();
|
||||
|
||||
const updateSceneAnimatedVideo = (sceneNumber: number, videoUrl: string) => {
|
||||
const nextMap = new Map(state.sceneAnimatedVideos || []);
|
||||
nextMap.set(sceneNumber, videoUrl);
|
||||
state.setSceneAnimatedVideos(nextMap);
|
||||
// Clear the blob URL for this scene so it reloads with the new video
|
||||
setVideoBlobUrls((prev) => {
|
||||
const next = new Map(prev);
|
||||
const oldBlobUrl = next.get(sceneNumber);
|
||||
if (oldBlobUrl) {
|
||||
URL.revokeObjectURL(oldBlobUrl);
|
||||
}
|
||||
next.delete(sceneNumber);
|
||||
return next;
|
||||
});
|
||||
// Clear any error state for this scene
|
||||
setVideoLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(sceneNumber);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAnimateSceneWithVoiceover = async () => {
|
||||
if (!hasScenes || !currentScene) {
|
||||
setError('Please generate your outline before animating scenes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneNumber = currentScene.scene_number || currentSceneIndex + 1;
|
||||
const sceneImageRelativeUrl = state.sceneImages?.get(sceneNumber);
|
||||
const sceneAudioRelativeUrl = state.sceneAudio?.get(sceneNumber);
|
||||
|
||||
if (!sceneImageRelativeUrl) {
|
||||
setError('Please generate an image for this scene before animating it.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sceneAudioRelativeUrl) {
|
||||
setError('Please generate narration audio for this scene before animating with voiceover.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimatingSceneNumber(sceneNumber);
|
||||
setError(null);
|
||||
updateSceneAnimationResume(sceneNumber, undefined);
|
||||
|
||||
const storyContextPayload = createStoryContextPayload();
|
||||
|
||||
try {
|
||||
console.info('[Outline] Animate scene with voiceover requested', {
|
||||
sceneNumber,
|
||||
image: sceneImageRelativeUrl,
|
||||
audio: sceneAudioRelativeUrl,
|
||||
});
|
||||
|
||||
// Start async task
|
||||
const startResponse = await storyWriterApi.animateSceneVoiceover({
|
||||
scene_number: sceneNumber,
|
||||
scene_data: currentScene,
|
||||
story_context: storyContextPayload,
|
||||
image_url: sceneImageRelativeUrl,
|
||||
audio_url: sceneAudioRelativeUrl,
|
||||
resolution: '720p',
|
||||
});
|
||||
|
||||
// Poll for completion (InfiniteTalk can take up to 10 minutes)
|
||||
const taskId = startResponse.task_id;
|
||||
let done = false;
|
||||
while (!done) {
|
||||
await new Promise((r) => setTimeout(r, 2000)); // Poll every 2 seconds
|
||||
const status = await storyWriterApi.getTaskStatus(taskId);
|
||||
if (status.status === 'completed') {
|
||||
done = true;
|
||||
const result = await storyWriterApi.getTaskResult(taskId);
|
||||
// Extract AnimateSceneResponse from result
|
||||
// The result can be either the AnimateSceneResponse directly or wrapped in a result field
|
||||
const animationResult = (result as any).result || result;
|
||||
const videoUrl = animationResult.video_url;
|
||||
const cost = animationResult.cost || 0;
|
||||
if (videoUrl) {
|
||||
updateSceneAnimatedVideo(sceneNumber, videoUrl);
|
||||
console.info('[Outline] Animate with voiceover completed', {
|
||||
sceneNumber,
|
||||
video: videoUrl,
|
||||
cost: cost,
|
||||
});
|
||||
} else {
|
||||
throw new Error('Video URL not found in result');
|
||||
}
|
||||
} else if (status.status === 'failed') {
|
||||
throw new Error(status.error || 'InfiniteTalk animation failed');
|
||||
}
|
||||
// Continue polling if status is 'pending' or 'processing'
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
const message = extractDetailMessage(detail, err.message || 'Failed to animate scene with voiceover.');
|
||||
setError(message);
|
||||
if (!handled) {
|
||||
console.error('[Outline] Animate scene with voiceover failed', err);
|
||||
}
|
||||
} finally {
|
||||
setAnimatingSceneNumber(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSceneAnimationResume = (sceneNumber: number, info?: SceneAnimationResume) => {
|
||||
const prevMap = state.sceneAnimationResumables || new Map<number, SceneAnimationResume>();
|
||||
const nextMap = new Map(prevMap);
|
||||
if (info) {
|
||||
nextMap.set(sceneNumber, info);
|
||||
} else {
|
||||
nextMap.delete(sceneNumber);
|
||||
}
|
||||
state.setSceneAnimationResumables(nextMap.size > 0 ? nextMap : null);
|
||||
};
|
||||
|
||||
const extractDetailMessage = (detail: any, fallback: string): string => {
|
||||
if (!detail) return fallback;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (typeof detail === 'object') {
|
||||
if (typeof detail.message === 'string') return detail.message;
|
||||
if (typeof detail.error === 'string') return detail.error;
|
||||
if (typeof detail.detail === 'string') return detail.detail;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const captureResumeOpportunity = (
|
||||
sceneNumber: number,
|
||||
duration: 5 | 10,
|
||||
detail: any
|
||||
): string | null => {
|
||||
if (!detail || typeof detail !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (!detail.resume_available || !detail.prediction_id) {
|
||||
return null;
|
||||
}
|
||||
const message =
|
||||
typeof detail.message === 'string'
|
||||
? detail.message
|
||||
: typeof detail.error === 'string'
|
||||
? detail.error
|
||||
: 'WaveSpeed is still finalizing this animation. Click Resume to download without extra cost.';
|
||||
|
||||
updateSceneAnimationResume(sceneNumber, {
|
||||
predictionId: detail.prediction_id,
|
||||
duration,
|
||||
message,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return message;
|
||||
};
|
||||
|
||||
const scenes = state.outlineScenes || [];
|
||||
const sceneCount = scenes.length;
|
||||
const hasScenes = state.isOutlineStructured && scenes.length > 0;
|
||||
const hasOutlineScenes = Boolean(state.outlineScenes && state.outlineScenes.length > 0);
|
||||
const resumableScenesArray = Array.from(sceneAnimationResumables.entries());
|
||||
const resumableSummaryMessage =
|
||||
resumableScenesArray.length === 0
|
||||
? null
|
||||
: resumableScenesArray.length === 1
|
||||
? resumableScenesArray[0][1]?.message ||
|
||||
`Scene ${resumableScenesArray[0][0]} animation is ready to resume without extra cost.`
|
||||
: `Scenes ${resumableScenesArray.map(([scene]) => scene).join(', ')} have WaveSpeed animations ready to resume without extra cost. Open each scene and click Resume Animation.`;
|
||||
|
||||
// removed old accordion renderer (unused)
|
||||
|
||||
@@ -98,10 +267,14 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
|
||||
// Get the current scene's image URL
|
||||
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
|
||||
const currentSceneResumeInfo = sceneAnimationResumables.get(currentSceneNumber) || null;
|
||||
const canAnimateCurrentScene = !animatingSceneNumber && !currentSceneResumeInfo;
|
||||
const isCurrentSceneAnimating = animatingSceneNumber === currentSceneNumber;
|
||||
const currentSceneImageUrl = sceneImages.get(currentSceneNumber);
|
||||
const hasImageLoadError = imageLoadError.has(currentSceneNumber);
|
||||
const currentSceneAudioUrl = sceneAudio.get(currentSceneNumber);
|
||||
const hasAudioLoadError = audioLoadError.has(currentSceneNumber);
|
||||
const hasAudioForScene = Boolean(currentSceneAudioUrl);
|
||||
|
||||
// Fetch image as blob with authentication
|
||||
useEffect(() => {
|
||||
@@ -128,8 +301,12 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
next.set(currentSceneNumber, blobUrl);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load image:', err);
|
||||
} catch (err: any) {
|
||||
// Only log non-404 errors (404 means file doesn't exist, which is acceptable)
|
||||
if (err?.response?.status !== 404) {
|
||||
console.error('Failed to load image:', err);
|
||||
}
|
||||
// Mark as error to prevent retries
|
||||
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}
|
||||
};
|
||||
@@ -137,6 +314,47 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
loadImage();
|
||||
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
|
||||
|
||||
// Fetch video as blob with authentication
|
||||
useEffect(() => {
|
||||
const animatedVideoRelativeUrl = sceneAnimatedVideos.get(currentSceneNumber);
|
||||
if (!animatedVideoRelativeUrl || hasVideoLoadError.has(currentSceneNumber) || videoBlobUrls.has(currentSceneNumber)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadVideo = async () => {
|
||||
try {
|
||||
// Remove query parameters (token) from URL if present, we'll use authenticated request instead
|
||||
const cleanUrl = animatedVideoRelativeUrl.split('?')[0];
|
||||
// Use relative URL path directly (aiApiClient will add base URL and auth)
|
||||
const videoUrl = cleanUrl.startsWith('/')
|
||||
? cleanUrl
|
||||
: `/${cleanUrl}`;
|
||||
// Use aiApiClient to get authenticated response with blob
|
||||
const response = await aiApiClient.get(videoUrl, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
const blob = response.data;
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
setVideoBlobUrls((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(currentSceneNumber, blobUrl);
|
||||
return next;
|
||||
});
|
||||
} catch (err: any) {
|
||||
// Only log non-404 errors (404 means file doesn't exist, which is acceptable)
|
||||
if (err?.response?.status !== 404) {
|
||||
console.error('Failed to load video:', err);
|
||||
}
|
||||
// Mark as error to prevent retries
|
||||
setVideoLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}
|
||||
};
|
||||
|
||||
loadVideo();
|
||||
}, [currentSceneNumber, sceneAnimatedVideos, hasVideoLoadError, videoBlobUrls]);
|
||||
|
||||
// Cleanup blob URLs when component unmounts or scenes change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -147,13 +365,36 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
audioBlobUrls.forEach((blobUrl) => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
videoBlobUrls.forEach((blobUrl) => {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
|
||||
const currentSceneAudioFullUrl = audioBlobUrls.get(currentSceneNumber) || null;
|
||||
const resolvedSceneAudioUrl =
|
||||
currentSceneAudioFullUrl ||
|
||||
(currentSceneAudioUrl ? storyWriterApi.getAudioUrl(currentSceneAudioUrl) : null);
|
||||
const currentSceneAnimatedVideoUrl = videoBlobUrls.get(currentSceneNumber) || null;
|
||||
|
||||
// Reset image load error when scene changes
|
||||
const createStoryContextPayload = () => ({
|
||||
persona: state.persona,
|
||||
story_setting: state.storySetting,
|
||||
characters: state.characters,
|
||||
plot_elements: state.plotElements,
|
||||
writing_style: state.writingStyle,
|
||||
story_tone: state.storyTone,
|
||||
narrative_pov: state.narrativePOV,
|
||||
audience_age_group: state.audienceAgeGroup,
|
||||
content_rating: state.contentRating,
|
||||
story_length: state.storyLength,
|
||||
premise: state.premise,
|
||||
outline: state.outline,
|
||||
story_content: state.storyContent,
|
||||
});
|
||||
|
||||
// Reset image/audio/video load errors when scene changes (to allow retry for new scene)
|
||||
useEffect(() => {
|
||||
setImageLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -165,6 +406,11 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
next.delete(currentSceneNumber);
|
||||
return next;
|
||||
});
|
||||
setVideoLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(currentSceneNumber);
|
||||
return next;
|
||||
});
|
||||
}, [currentSceneNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -192,9 +438,20 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
|
||||
const loadAudio = async () => {
|
||||
try {
|
||||
const audioPath = currentSceneAudioUrl.startsWith('/')
|
||||
? currentSceneAudioUrl
|
||||
: `/${currentSceneAudioUrl}`;
|
||||
// Remove query parameters (token) from URL if present, we'll use authenticated request instead
|
||||
const cleanUrl = currentSceneAudioUrl.split('?')[0];
|
||||
// Normalize path - ensure it starts with /api/story/audio/
|
||||
let audioPath = cleanUrl.startsWith('/')
|
||||
? cleanUrl
|
||||
: `/${cleanUrl}`;
|
||||
|
||||
// If path doesn't include /api/story/audio/, add it
|
||||
if (!audioPath.includes('/api/story/audio/')) {
|
||||
// Extract filename from path
|
||||
const filename = cleanUrl.split('/').pop() || cleanUrl;
|
||||
audioPath = `/api/story/audio/${filename}`;
|
||||
}
|
||||
|
||||
const response = await aiApiClient.get(audioPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
@@ -210,8 +467,19 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
next.set(currentSceneNumber, blobUrl);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load audio:', err);
|
||||
} catch (err: any) {
|
||||
// Only log non-404 errors (404 means file doesn't exist, which is acceptable)
|
||||
if (err?.response?.status !== 404) {
|
||||
console.error(`Failed to load audio for scene ${currentSceneNumber}:`, err);
|
||||
console.error(`Audio URL was: ${currentSceneAudioUrl}`);
|
||||
|
||||
// If auth error, log more details
|
||||
if (err?.response?.status === 401) {
|
||||
console.error(`Authentication failed for audio file. Make sure auth token is set.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as error to prevent retries
|
||||
setAudioLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}
|
||||
};
|
||||
@@ -444,6 +712,104 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimateScene = async () => {
|
||||
if (!hasScenes || !currentScene) {
|
||||
setError('Please generate your outline before animating scenes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sceneNumber = currentScene.scene_number || currentSceneIndex + 1;
|
||||
const sceneImageRelativeUrl = state.sceneImages?.get(sceneNumber);
|
||||
if (!sceneImageRelativeUrl) {
|
||||
setError('Please generate an image for this scene before animating it.');
|
||||
return;
|
||||
}
|
||||
|
||||
setAnimatingSceneNumber(sceneNumber);
|
||||
setError(null);
|
||||
updateSceneAnimationResume(sceneNumber, undefined);
|
||||
|
||||
const storyContextPayload = createStoryContextPayload();
|
||||
|
||||
const animationDuration: 5 | 10 = 5;
|
||||
|
||||
try {
|
||||
console.info(
|
||||
`[Outline] Animate scene requested`,
|
||||
{ sceneNumber, duration: 5, image: sceneImageRelativeUrl }
|
||||
);
|
||||
const response = await storyWriterApi.animateScene({
|
||||
scene_number: sceneNumber,
|
||||
scene_data: currentScene,
|
||||
story_context: storyContextPayload,
|
||||
image_url: sceneImageRelativeUrl,
|
||||
duration: animationDuration,
|
||||
});
|
||||
|
||||
updateSceneAnimatedVideo(sceneNumber, response.video_url);
|
||||
updateSceneAnimationResume(sceneNumber, undefined);
|
||||
console.info(
|
||||
`[Outline] Animate scene completed`,
|
||||
{
|
||||
sceneNumber,
|
||||
video: response.video_url,
|
||||
cost: response.cost,
|
||||
prediction: response.prediction_id || 'n/a',
|
||||
}
|
||||
);
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
const resumeMessage = captureResumeOpportunity(sceneNumber, animationDuration, detail);
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
const message = resumeMessage || extractDetailMessage(detail, err.message || 'Failed to animate scene.');
|
||||
setError(message);
|
||||
if (!resumeMessage || !handled) {
|
||||
console.error('[Outline] Animate scene failed', err);
|
||||
}
|
||||
} finally {
|
||||
setAnimatingSceneNumber(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResumeSceneAnimation = async (
|
||||
sceneNumber: number,
|
||||
resumeInfo: SceneAnimationResume
|
||||
) => {
|
||||
setAnimatingSceneNumber(sceneNumber);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.info('[Outline] Resume scene requested', {
|
||||
sceneNumber,
|
||||
prediction: resumeInfo.predictionId,
|
||||
});
|
||||
|
||||
const response = await storyWriterApi.resumeAnimateScene({
|
||||
prediction_id: resumeInfo.predictionId,
|
||||
scene_number: sceneNumber,
|
||||
duration: resumeInfo.duration,
|
||||
});
|
||||
|
||||
updateSceneAnimatedVideo(sceneNumber, response.video_url);
|
||||
updateSceneAnimationResume(sceneNumber, undefined);
|
||||
|
||||
console.info('[Outline] Resume scene completed', {
|
||||
sceneNumber,
|
||||
video: response.video_url,
|
||||
cost: response.cost,
|
||||
prediction: response.prediction_id || resumeInfo.predictionId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const detail = err?.response?.data?.detail;
|
||||
const message = extractDetailMessage(detail, err.message || 'Failed to resume animation.');
|
||||
setError(message);
|
||||
await triggerSubscriptionError(err);
|
||||
console.error('[Outline] Resume scene failed', err);
|
||||
} finally {
|
||||
setAnimatingSceneNumber(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateCurrentSceneImage = async () => {
|
||||
if (!hasScenes || !currentScene) return;
|
||||
setIsRegeneratingSceneImage(true);
|
||||
@@ -532,6 +898,12 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{resumableSummaryMessage && (
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
{resumableSummaryMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!state.premise && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
Please generate a premise first in the Setup phase.
|
||||
@@ -552,17 +924,24 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
imageUrl={currentSceneImageFullUrl}
|
||||
onImageError={() => setImageLoadError((prev) => new Set(prev).add(currentSceneNumber))}
|
||||
narrationEnabled={!!state.enableNarration}
|
||||
audioUrl={
|
||||
currentSceneAudioFullUrl || (state.sceneAudio && state.sceneAudio.get(currentSceneNumber)
|
||||
? storyWriterApi.getAudioUrl(state.sceneAudio.get(currentSceneNumber) || '')
|
||||
: null)
|
||||
}
|
||||
audioUrl={resolvedSceneAudioUrl || null}
|
||||
hasAudio={hasAudioForScene}
|
||||
onOpenImageModal={openImageModal}
|
||||
onOpenAudioModal={openAudioModal}
|
||||
onOpenCharactersModal={openCharactersModal}
|
||||
onOpenKeyEventsModal={openKeyEventsModal}
|
||||
onOpenTitleModal={openTitleModal}
|
||||
onOpenEditModal={openEditModal}
|
||||
onAnimateScene={canAnimateCurrentScene ? handleAnimateScene : undefined}
|
||||
onAnimateWithVoiceover={hasAudioForScene ? handleAnimateSceneWithVoiceover : undefined}
|
||||
onResumeScene={
|
||||
currentSceneResumeInfo && !animatingSceneNumber
|
||||
? () => handleResumeSceneAnimation(currentSceneNumber, currentSceneResumeInfo)
|
||||
: undefined
|
||||
}
|
||||
resumeInfo={currentSceneResumeInfo}
|
||||
isAnimatingScene={isCurrentSceneAnimating}
|
||||
animatedVideoUrl={currentSceneAnimatedVideoUrl}
|
||||
/>
|
||||
<OutlineActionsBar
|
||||
isGenerating={isGenerating}
|
||||
@@ -617,6 +996,50 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
(state.setOutlineScenes as any)(updated);
|
||||
setIsImageModalOpen(false);
|
||||
}}
|
||||
onRegenerate={async (prompt: string) => {
|
||||
if (!hasScenes || !currentScene) return;
|
||||
setIsRegeneratingSceneImage(true);
|
||||
try {
|
||||
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
|
||||
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
|
||||
|
||||
const resp = await storyWriterApi.regenerateSceneImage({
|
||||
scene_number: sceneNum,
|
||||
scene_title: sceneTitle,
|
||||
prompt: prompt.trim(),
|
||||
provider: state.imageProvider || undefined,
|
||||
width: state.imageWidth,
|
||||
height: state.imageHeight,
|
||||
model: state.imageModel || undefined,
|
||||
});
|
||||
|
||||
if (resp.success && resp.image_url) {
|
||||
const nextMap = new Map(state.sceneImages || []);
|
||||
nextMap.set(sceneNum, resp.image_url);
|
||||
state.setSceneImages(nextMap);
|
||||
|
||||
// Update the scene with the new prompt if generation was successful
|
||||
const updated = [...scenes];
|
||||
updated[currentSceneIndex] = { ...updated[currentSceneIndex], image_prompt: prompt.trim() };
|
||||
(state.setOutlineScenes as any)(updated);
|
||||
setImagePromptDraft(prompt.trim());
|
||||
|
||||
// Close the modal after successful regeneration
|
||||
setIsImageModalOpen(false);
|
||||
} else {
|
||||
throw new Error(resp.error || 'Failed to regenerate image');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to regenerate scene image:', err);
|
||||
throw err; // Re-throw to be handled by modal
|
||||
} finally {
|
||||
setIsRegeneratingSceneImage(false);
|
||||
}
|
||||
}}
|
||||
imageProvider={state.imageProvider}
|
||||
imageWidth={state.imageWidth}
|
||||
imageHeight={state.imageHeight}
|
||||
imageModel={state.imageModel}
|
||||
/>
|
||||
<AudioScriptModal
|
||||
open={isAudioModalOpen}
|
||||
@@ -644,6 +1067,94 @@ const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
|
||||
? storyWriterApi.getAudioUrl(state.sceneAudio.get(currentSceneNumber) || '')
|
||||
: currentSceneAudioFullUrl) || null
|
||||
}
|
||||
onGenerateAI={async (params: {
|
||||
text: string;
|
||||
voice_id?: string;
|
||||
speed?: number;
|
||||
volume?: number;
|
||||
pitch?: number;
|
||||
emotion?: string;
|
||||
}) => {
|
||||
if (!hasScenes || !currentScene) return;
|
||||
setIsRegeneratingSceneAudio(true);
|
||||
try {
|
||||
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
|
||||
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
|
||||
|
||||
const resp = await storyWriterApi.generateAIAudio({
|
||||
scene_number: sceneNum,
|
||||
scene_title: sceneTitle,
|
||||
text: params.text.trim(),
|
||||
voice_id: params.voice_id || 'Wise_Woman',
|
||||
speed: params.speed !== undefined ? params.speed : 1.0,
|
||||
volume: params.volume !== undefined ? params.volume : 1.0,
|
||||
pitch: params.pitch !== undefined ? params.pitch : 0.0,
|
||||
emotion: params.emotion || 'happy',
|
||||
});
|
||||
|
||||
if (resp.success && resp.audio_url) {
|
||||
const nextMap = new Map(state.sceneAudio || []);
|
||||
nextMap.set(sceneNum, resp.audio_url);
|
||||
state.setSceneAudio(nextMap);
|
||||
|
||||
// Update the scene with the new audio_narration if generation was successful
|
||||
const updated = [...scenes];
|
||||
updated[currentSceneIndex] = { ...updated[currentSceneIndex], audio_narration: params.text.trim() };
|
||||
(state.setOutlineScenes as any)(updated);
|
||||
setAudioScriptDraft(params.text.trim());
|
||||
|
||||
// Close the modal after successful generation
|
||||
setIsAudioModalOpen(false);
|
||||
} else {
|
||||
throw new Error(resp.error || 'Failed to generate AI audio');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to generate AI audio:', err);
|
||||
throw err; // Re-throw to be handled by modal
|
||||
} finally {
|
||||
setIsRegeneratingSceneAudio(false);
|
||||
}
|
||||
}}
|
||||
onGenerateFree={async (text: string) => {
|
||||
if (!hasScenes || !currentScene) return;
|
||||
setIsRegeneratingSceneAudio(true);
|
||||
try {
|
||||
const sceneNum = currentScene.scene_number || currentSceneIndex + 1;
|
||||
const sceneTitle = currentScene.title || `Scene ${sceneNum}`;
|
||||
|
||||
const resp = await storyWriterApi.generateFreeAudio({
|
||||
scene_number: sceneNum,
|
||||
scene_title: sceneTitle,
|
||||
text: text.trim(),
|
||||
provider: state.audioProvider || 'gtts',
|
||||
lang: state.audioLang || 'en',
|
||||
slow: state.audioSlow || false,
|
||||
rate: state.audioRate || 150,
|
||||
});
|
||||
|
||||
if (resp.success && resp.audio_url) {
|
||||
const nextMap = new Map(state.sceneAudio || []);
|
||||
nextMap.set(sceneNum, resp.audio_url);
|
||||
state.setSceneAudio(nextMap);
|
||||
|
||||
// Update the scene with the new audio_narration if generation was successful
|
||||
const updated = [...scenes];
|
||||
updated[currentSceneIndex] = { ...updated[currentSceneIndex], audio_narration: text.trim() };
|
||||
(state.setOutlineScenes as any)(updated);
|
||||
setAudioScriptDraft(text.trim());
|
||||
|
||||
// Close the modal after successful generation
|
||||
setIsAudioModalOpen(false);
|
||||
} else {
|
||||
throw new Error(resp.error || 'Failed to generate free audio');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to generate free audio:', err);
|
||||
throw err; // Re-throw to be handled by modal
|
||||
} finally {
|
||||
setIsRegeneratingSceneAudio(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CharactersModal
|
||||
open={isCharactersModalOpen}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||
import {
|
||||
Box, Button, Dialog, DialogActions, DialogContent, DialogTitle,
|
||||
TextField, Divider, CircularProgress, Typography, Tooltip, IconButton,
|
||||
Slider, FormControl, InputLabel, Select, MenuItem, FormHelperText,
|
||||
ToggleButtonGroup, ToggleButton
|
||||
} from '@mui/material';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import { OperationButton } from '../../../shared/OperationButton';
|
||||
|
||||
interface AudioScriptModalProps {
|
||||
open: boolean;
|
||||
@@ -18,14 +27,114 @@ interface AudioScriptModalProps {
|
||||
onChangeSlow: (v: boolean) => void;
|
||||
onChangeRate: (v: number) => void;
|
||||
audioUrl?: string | null;
|
||||
// audio generation callbacks - now with full parameters
|
||||
onGenerateAI?: (params: {
|
||||
text: string;
|
||||
voice_id?: string;
|
||||
speed?: number;
|
||||
volume?: number;
|
||||
pitch?: number;
|
||||
emotion?: string;
|
||||
}) => Promise<void>;
|
||||
onGenerateFree?: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// Available voice IDs from WaveSpeed Minimax
|
||||
const AVAILABLE_VOICES = [
|
||||
{ value: 'Wise_Woman', label: 'Wise Woman', description: 'Warm, authoritative female voice' },
|
||||
{ value: 'Friendly_Person', label: 'Friendly Person', description: 'Approachable and conversational' },
|
||||
{ value: 'Inspirational_girl', label: 'Inspirational Girl', description: 'Energetic and motivating' },
|
||||
{ value: 'Deep_Voice_Man', label: 'Deep Voice Man', description: 'Rich, deep male voice' },
|
||||
{ value: 'Calm_Woman', label: 'Calm Woman', description: 'Peaceful and soothing' },
|
||||
{ value: 'Casual_Guy', label: 'Casual Guy', description: 'Relaxed and informal' },
|
||||
{ value: 'Lively_Girl', label: 'Lively Girl', description: 'Vibrant and enthusiastic' },
|
||||
{ value: 'Patient_Man', label: 'Patient Man', description: 'Steady and reassuring' },
|
||||
{ value: 'Young_Knight', label: 'Young Knight', description: 'Brave and confident' },
|
||||
{ value: 'Determined_Man', label: 'Determined Man', description: 'Strong and resolute' },
|
||||
{ value: 'Lovely_Girl', label: 'Lovely Girl', description: 'Sweet and charming' },
|
||||
{ value: 'Decent_Boy', label: 'Decent Boy', description: 'Polite and well-mannered' },
|
||||
{ value: 'Imposing_Manner', label: 'Imposing Manner', description: 'Commanding and powerful' },
|
||||
{ value: 'Elegant_Man', label: 'Elegant Man', description: 'Sophisticated and refined' },
|
||||
{ value: 'Abbess', label: 'Abbess', description: 'Dignified and wise' },
|
||||
{ value: 'Sweet_Girl_2', label: 'Sweet Girl 2', description: 'Gentle and kind' },
|
||||
{ value: 'Exuberant_Girl', label: 'Exuberant Girl', description: 'Joyful and energetic' },
|
||||
];
|
||||
|
||||
const EMOTIONS = [
|
||||
{ value: 'happy', label: 'Happy', description: 'Cheerful and upbeat tone' },
|
||||
{ value: 'sad', label: 'Sad', description: 'Melancholic and somber tone' },
|
||||
{ value: 'angry', label: 'Angry', description: 'Intense and forceful tone' },
|
||||
{ value: 'fear', label: 'Fear', description: 'Anxious and nervous tone' },
|
||||
{ value: 'surprised', label: 'Surprised', description: 'Astonished and amazed tone' },
|
||||
{ value: 'neutral', label: 'Neutral', description: 'Calm and balanced tone (default)' },
|
||||
];
|
||||
|
||||
const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
|
||||
open, sceneNumber, value, onChange, onClose, onSave,
|
||||
audioProvider, audioLang, audioSlow, audioRate,
|
||||
onChangeProvider, onChangeLang, onChangeSlow, onChangeRate,
|
||||
audioUrl,
|
||||
onGenerateAI,
|
||||
onGenerateFree,
|
||||
}) => {
|
||||
const [isGeneratingAI, setIsGeneratingAI] = React.useState(false);
|
||||
const [isGeneratingFree, setIsGeneratingFree] = React.useState(false);
|
||||
const [generateError, setGenerateError] = React.useState<string | null>(null);
|
||||
|
||||
// Audio type toggle - default to 'free'
|
||||
const [audioType, setAudioType] = React.useState<'free' | 'ai'>('free');
|
||||
|
||||
// AI Audio generation parameters with intelligent defaults
|
||||
const [voiceId, setVoiceId] = React.useState<string>('Wise_Woman');
|
||||
const [customVoiceId, setCustomVoiceId] = React.useState<string>('');
|
||||
const [useCustomVoice, setUseCustomVoice] = React.useState<boolean>(false);
|
||||
const [emotion, setEmotion] = React.useState<string>('happy');
|
||||
const [speed, setSpeed] = React.useState<number>(1.0);
|
||||
const [volume, setVolume] = React.useState<number>(1.0);
|
||||
const [pitch, setPitch] = React.useState<number>(0.0);
|
||||
|
||||
const handleGenerateAI = async () => {
|
||||
if (!onGenerateAI || !value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingAI(true);
|
||||
setGenerateError(null);
|
||||
try {
|
||||
await onGenerateAI({
|
||||
text: value.trim(),
|
||||
voice_id: useCustomVoice ? customVoiceId : voiceId,
|
||||
emotion: emotion,
|
||||
speed: speed,
|
||||
volume: volume,
|
||||
pitch: pitch,
|
||||
});
|
||||
// Optionally close modal after successful generation
|
||||
// onClose();
|
||||
} catch (err: any) {
|
||||
setGenerateError(err?.response?.data?.detail || err?.message || 'Failed to generate AI audio');
|
||||
} finally {
|
||||
setIsGeneratingAI(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateFree = async () => {
|
||||
if (!onGenerateFree || !value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingFree(true);
|
||||
setGenerateError(null);
|
||||
try {
|
||||
await onGenerateFree(value.trim());
|
||||
// Optionally close modal after successful generation
|
||||
// onClose();
|
||||
} catch (err: any) {
|
||||
setGenerateError(err?.response?.data?.detail || err?.message || 'Failed to generate free audio');
|
||||
} finally {
|
||||
setIsGeneratingFree(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -42,14 +151,43 @@ const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Audio Narration Script (Scene {sceneNumber})</DialogTitle>
|
||||
<DialogContent dividers sx={{ color: '#2C2416' }}>
|
||||
<DialogContent dividers sx={{ color: '#2C2416', bgcolor: '#fff' }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
'& .MuiFormLabel-root': { color: '#6b5846' },
|
||||
'& .MuiInputBase-root': { color: '#2C2416' },
|
||||
gap: 3,
|
||||
pt: 1,
|
||||
'& .MuiFormLabel-root': { color: '#5D4037', fontWeight: 500 },
|
||||
'& .MuiInputBase-root': {
|
||||
color: '#2C2416',
|
||||
bgcolor: '#fff',
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(0, 0, 0, 0.23)',
|
||||
},
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'rgba(0, 0, 0, 0.87)',
|
||||
},
|
||||
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: 'primary.main',
|
||||
borderWidth: '2px',
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#2C2416',
|
||||
},
|
||||
'& textarea': {
|
||||
color: '#2C2416',
|
||||
},
|
||||
'& .MuiSelect-select': {
|
||||
color: '#2C2416',
|
||||
},
|
||||
'& .MuiFormHelperText-root': {
|
||||
color: 'rgba(0, 0, 0, 0.6)',
|
||||
},
|
||||
'& .MuiMenuItem-root': {
|
||||
color: '#2C2416',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{audioUrl ? (
|
||||
@@ -73,40 +211,387 @@ const AudioScriptModal: React.FC<AudioScriptModalProps> = ({
|
||||
multiline
|
||||
minRows={6}
|
||||
fullWidth
|
||||
placeholder="Enter the narration text for this scene..."
|
||||
sx={{
|
||||
'& .MuiInputBase-input': {
|
||||
color: '#2C2416',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Audio Provider"
|
||||
value={audioProvider}
|
||||
onChange={(e) => onChangeProvider(e.target.value)}
|
||||
SelectProps={{ native: true }}
|
||||
>
|
||||
<option value="gtts">gTTS</option>
|
||||
<option value="pyttsx3">pyttsx3</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Language (e.g., en, hi)"
|
||||
value={audioLang}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Slow (gTTS)"
|
||||
value={audioSlow ? 'true' : 'false'}
|
||||
onChange={(e) => onChangeSlow(e.target.value === 'true')}
|
||||
SelectProps={{ native: true }}
|
||||
>
|
||||
<option value="false">Normal</option>
|
||||
<option value="true">Slow</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Rate (pyttsx3)"
|
||||
value={audioRate}
|
||||
onChange={(e) => onChangeRate(Number(e.target.value))}
|
||||
inputProps={{ min: 50, max: 300, step: 10 }}
|
||||
/>
|
||||
|
||||
{generateError && (
|
||||
<Box sx={{ color: 'error.main', fontSize: '0.875rem', mt: -1 }}>
|
||||
{generateError}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Audio Type Toggle */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1.5, fontWeight: 600, color: '#5D4037' }}>
|
||||
Audio Type
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
value={audioType}
|
||||
exclusive
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue !== null) {
|
||||
setAudioType(newValue);
|
||||
setGenerateError(null);
|
||||
}
|
||||
}}
|
||||
aria-label="audio type"
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiToggleButton-root': {
|
||||
textTransform: 'none',
|
||||
borderColor: 'rgba(0, 0, 0, 0.23)',
|
||||
color: '#5D4037',
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark',
|
||||
},
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.04)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="free" aria-label="free audio">
|
||||
<VolumeUpIcon sx={{ mr: 1 }} />
|
||||
Free Audio (gTTS)
|
||||
</ToggleButton>
|
||||
<ToggleButton value="ai" aria-label="ai audio">
|
||||
<SmartToyIcon sx={{ mr: 1 }} />
|
||||
AI Audio (Minimax)
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
|
||||
{/* Generate Button - Context aware based on audio type */}
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{audioType === 'ai' && onGenerateAI && (
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'audio',
|
||||
model: 'minimax/speech-02-hd',
|
||||
tokens_requested: value.trim().length, // Every character is 1 token
|
||||
operation_type: 'audio_generation',
|
||||
actual_provider_name: 'wavespeed',
|
||||
}}
|
||||
label="Generate AI Audio"
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={<SmartToyIcon />}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={handleGenerateAI}
|
||||
disabled={isGeneratingAI || isGeneratingFree || !value.trim()}
|
||||
loading={isGeneratingAI}
|
||||
sx={{ flex: 1, minWidth: '200px' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{audioType === 'free' && onGenerateFree && (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={isGeneratingFree ? <CircularProgress size={16} /> : <VolumeUpIcon />}
|
||||
onClick={handleGenerateFree}
|
||||
disabled={isGeneratingAI || isGeneratingFree || !value.trim()}
|
||||
sx={{ flex: 1, minWidth: '200px' }}
|
||||
>
|
||||
{isGeneratingFree ? 'Generating...' : 'Generate Free Audio (gTTS)'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
{/* Settings - Conditionally shown based on audio type */}
|
||||
{audioType === 'ai' && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600, color: '#5D4037' }}>
|
||||
AI Audio Generation Settings
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
{/* Voice Selection */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Voice</InputLabel>
|
||||
<Select
|
||||
value={useCustomVoice ? 'custom' : voiceId}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === 'custom') {
|
||||
setUseCustomVoice(true);
|
||||
} else {
|
||||
setUseCustomVoice(false);
|
||||
setVoiceId(e.target.value);
|
||||
}
|
||||
}}
|
||||
label="Voice"
|
||||
renderValue={(value) => {
|
||||
if (value === 'custom') {
|
||||
return customVoiceId || 'Custom Voice ID';
|
||||
}
|
||||
const voice = AVAILABLE_VOICES.find(v => v.value === value);
|
||||
return voice ? voice.label : value;
|
||||
}}
|
||||
>
|
||||
{AVAILABLE_VOICES.map((voice) => (
|
||||
<MenuItem key={voice.value} value={voice.value}>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{voice.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{voice.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem value="custom">
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, fontStyle: 'italic' }}>
|
||||
Custom Voice ID...
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Use a voice ID from voice cloning
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Choose a voice that matches your story's tone
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
Current Voice ID: {voiceId}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5 }}>
|
||||
You can use system voices above or enter a custom voice ID from voice cloning.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
Learn more:{' '}
|
||||
<a
|
||||
href="https://wavespeed.ai/models/minimax/voice-clone"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#90caf9' }}
|
||||
>
|
||||
Voice Cloning Guide
|
||||
</a>
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Custom Voice ID Input (shown when custom voice is selected) */}
|
||||
{useCustomVoice && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Custom Voice ID"
|
||||
value={customVoiceId}
|
||||
onChange={(e) => setCustomVoiceId(e.target.value)}
|
||||
helperText="Enter your custom voice ID from voice cloning"
|
||||
placeholder="your-custom-voice-id"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Emotion Selection */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Emotion</InputLabel>
|
||||
<Select
|
||||
value={emotion}
|
||||
onChange={(e) => setEmotion(e.target.value)}
|
||||
label="Emotion"
|
||||
>
|
||||
{EMOTIONS.map((em) => (
|
||||
<MenuItem key={em.value} value={em.value}>
|
||||
<Box>
|
||||
<Typography variant="body2">{em.label}</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{em.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>
|
||||
Select the emotional tone for the narration
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Speed Slider */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: '60px' }}>
|
||||
Speed
|
||||
</Typography>
|
||||
<Slider
|
||||
value={speed}
|
||||
onChange={(_, newValue) => setSpeed(newValue as number)}
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value}x`}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ minWidth: '40px', textAlign: 'right' }}>
|
||||
{speed.toFixed(1)}x
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Speech speed (0.5x = slow, 1.0x = normal, 2.0x = fast)
|
||||
<Tooltip
|
||||
title="Adjust how fast the narration speaks. 1.0 is normal speed, suitable for most content."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
|
||||
{/* Volume Slider */}
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: '60px' }}>
|
||||
Volume
|
||||
</Typography>
|
||||
<Slider
|
||||
value={volume}
|
||||
onChange={(_, newValue) => setVolume(newValue as number)}
|
||||
min={0.1}
|
||||
max={10.0}
|
||||
step={0.1}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value.toFixed(1)}`}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ minWidth: '40px', textAlign: 'right' }}>
|
||||
{volume.toFixed(1)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Audio volume level (0.1 = quiet, 1.0 = normal, 10.0 = loud)
|
||||
<Tooltip
|
||||
title="Control the loudness of the audio. 1.0 is standard volume. Increase for emphasis, decrease for subtlety."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
|
||||
{/* Pitch Slider */}
|
||||
<Box sx={{ gridColumn: { xs: '1', md: '1 / -1' } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ minWidth: '60px' }}>
|
||||
Pitch
|
||||
</Typography>
|
||||
<Slider
|
||||
value={pitch}
|
||||
onChange={(_, newValue) => setPitch(newValue as number)}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value > 0 ? '+' : ''}${value}`}
|
||||
marks={[
|
||||
{ value: -12, label: '-12' },
|
||||
{ value: 0, label: '0' },
|
||||
{ value: 12, label: '+12' },
|
||||
]}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ minWidth: '50px', textAlign: 'right' }}>
|
||||
{pitch > 0 ? '+' : ''}{pitch}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormHelperText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
Voice pitch adjustment (-12 = lower, 0 = normal, +12 = higher)
|
||||
<Tooltip
|
||||
title="Adjust the pitch of the voice. Negative values make the voice deeper, positive values make it higher. 0 keeps the natural voice pitch."
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<InfoOutlinedIcon sx={{ fontSize: '0.875rem', color: 'text.secondary', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</FormHelperText>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{audioType === 'free' && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600, color: '#5D4037' }}>
|
||||
Free Audio (gTTS) Settings
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, gap: 2 }}>
|
||||
<TextField
|
||||
select
|
||||
label="Audio Provider"
|
||||
value={audioProvider}
|
||||
onChange={(e) => onChangeProvider(e.target.value)}
|
||||
SelectProps={{ native: true }}
|
||||
helperText="Text-to-speech engine for free audio generation"
|
||||
>
|
||||
<option value="gtts">gTTS (Google Text-to-Speech)</option>
|
||||
<option value="pyttsx3">pyttsx3 (Offline)</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
label="Language"
|
||||
value={audioLang}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
helperText="Language code (e.g., en for English, hi for Hindi)"
|
||||
placeholder="en"
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label="Speech Speed (gTTS)"
|
||||
value={audioSlow ? 'true' : 'false'}
|
||||
onChange={(e) => onChangeSlow(e.target.value === 'true')}
|
||||
SelectProps={{ native: true }}
|
||||
helperText="Whether to speak slowly (useful for clarity)"
|
||||
>
|
||||
<option value="false">Normal Speed</option>
|
||||
<option value="true">Slow Speed</option>
|
||||
</TextField>
|
||||
<TextField
|
||||
type="number"
|
||||
label="Speech Rate (pyttsx3)"
|
||||
value={audioRate}
|
||||
onChange={(e) => onChangeRate(Number(e.target.value))}
|
||||
inputProps={{ min: 50, max: 300, step: 10 }}
|
||||
helperText="Words per minute (50-300, default: 150)"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Tooltip, Chip } from '@mui/material';
|
||||
import { Box, Typography, Tooltip, Chip, CircularProgress } from '@mui/material';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import OutlineHoverActions from './OutlineHoverActions';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import TipsAndUpdatesIcon from '@mui/icons-material/TipsAndUpdates';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import { OperationButton } from '../../../shared/OperationButton';
|
||||
import { leftPageVariants, rightPageVariants } from './pageVariants';
|
||||
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
|
||||
import { StoryScene } from '../../../../services/storyWriterApi';
|
||||
import type { SceneAnimationResume } from '../../../../hooks/useStoryWriterState';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
@@ -33,12 +38,19 @@ interface BookPagesProps {
|
||||
|
||||
narrationEnabled: boolean;
|
||||
audioUrl: string | null;
|
||||
hasAudio: boolean;
|
||||
onOpenImageModal: () => void;
|
||||
onOpenAudioModal: () => void;
|
||||
onOpenCharactersModal: () => void;
|
||||
onOpenKeyEventsModal: () => void;
|
||||
onOpenTitleModal: () => void;
|
||||
onOpenEditModal: () => void;
|
||||
onAnimateScene?: () => void;
|
||||
onResumeScene?: () => void;
|
||||
onAnimateWithVoiceover?: () => void;
|
||||
isAnimatingScene?: boolean;
|
||||
animatedVideoUrl?: string | null;
|
||||
resumeInfo?: SceneAnimationResume | null;
|
||||
}
|
||||
|
||||
const BookPages: React.FC<BookPagesProps> = ({
|
||||
@@ -56,12 +68,22 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
onOpenImageModal,
|
||||
onOpenAudioModal,
|
||||
audioUrl,
|
||||
hasAudio,
|
||||
onOpenCharactersModal,
|
||||
onOpenKeyEventsModal,
|
||||
onOpenTitleModal,
|
||||
onOpenEditModal,
|
||||
onAnimateScene,
|
||||
onResumeScene,
|
||||
onAnimateWithVoiceover,
|
||||
isAnimatingScene,
|
||||
animatedVideoUrl,
|
||||
resumeInfo,
|
||||
}) => {
|
||||
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
|
||||
const showAnimatedVideo = Boolean(animatedVideoUrl);
|
||||
const hasImage = Boolean(imageUrl);
|
||||
const hasMedia = showAnimatedVideo || hasImage;
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -213,13 +235,43 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
overflowY: 'auto',
|
||||
mt: 3,
|
||||
display: 'grid',
|
||||
gridTemplateRows: imageUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
|
||||
gridTemplateRows: hasMedia ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
|
||||
alignContent: 'start',
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', '&:hover .left-image-actions': { opacity: 1, pointerEvents: 'auto' } }}>
|
||||
{imageUrl ? (
|
||||
{showAnimatedVideo ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 12px 24px rgba(0, 0, 0, 0.2)',
|
||||
border: '3px solid rgba(120, 90, 60, 0.25)',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="video"
|
||||
src={animatedVideoUrl ?? undefined}
|
||||
poster={imageUrl ?? undefined}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
controls
|
||||
playsInline
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
minHeight: '300px',
|
||||
maxHeight: '500px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : hasImage ? (
|
||||
<>
|
||||
{/* Removed 'Scene Illustration' heading for cleaner look */}
|
||||
<Box
|
||||
@@ -239,7 +291,7 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={imageUrl}
|
||||
src={imageUrl || undefined}
|
||||
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
|
||||
sx={{
|
||||
width: '100%',
|
||||
@@ -258,11 +310,13 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.2s ease',
|
||||
zIndex: 5,
|
||||
zIndex: 5,
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Edit scene image prompt">
|
||||
@@ -286,6 +340,152 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
<EditNoteIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
{hasImage && onAnimateScene && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
|
||||
>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'video',
|
||||
model: 'kling-v2.5-turbo-std-5s',
|
||||
operation_type: 'scene_animation',
|
||||
actual_provider_name: 'wavespeed',
|
||||
}}
|
||||
label="Animate Scene"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
showCost
|
||||
checkOnHover
|
||||
checkOnMount={false}
|
||||
onClick={onAnimateScene}
|
||||
disabled={isAnimatingScene}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
padding: '8px',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #1f8a70 0%, #32d9c8 100%)',
|
||||
boxShadow: '0 8px 16px rgba(31,138,112,0.35)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #1a7a60 0%, #2dc9b8 100%)',
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
margin: 0,
|
||||
},
|
||||
'& .MuiButton-label': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
tooltipPlacement="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{hasImage && hasAudio && onAnimateWithVoiceover && (
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
|
||||
>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'video',
|
||||
model: 'wavespeed-ai/infinitetalk',
|
||||
operation_type: 'scene_animation_voiceover',
|
||||
actual_provider_name: 'wavespeed',
|
||||
}}
|
||||
label="Animate with Voiceover"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<GraphicEqIcon />}
|
||||
showCost
|
||||
checkOnHover
|
||||
checkOnMount={false}
|
||||
onClick={onAnimateWithVoiceover}
|
||||
disabled={isAnimatingScene}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
padding: '8px',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #733dd9 0%, #bb86fc 100%)',
|
||||
boxShadow: '0 8px 16px rgba(115,61,217,0.35)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #6030ba 0%, #a974f1 100%)',
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
margin: 0,
|
||||
},
|
||||
'& .MuiButton-label': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
tooltipPlacement="left"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{resumeInfo && onResumeScene && (
|
||||
<Tooltip
|
||||
title={resumeInfo.message || 'Resume animation download (no extra cost)'}
|
||||
placement="left"
|
||||
>
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
sx={{ display: 'inline-flex', pointerEvents: 'auto' }}
|
||||
>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'video',
|
||||
model: 'kling-v2.5-turbo-std-resume',
|
||||
operation_type: 'scene_animation_resume',
|
||||
actual_provider_name: 'wavespeed',
|
||||
}}
|
||||
label="Resume Animation"
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<ReplayIcon />}
|
||||
showCost={false}
|
||||
checkOnHover={false}
|
||||
checkOnMount={false}
|
||||
onClick={onResumeScene}
|
||||
disabled={isAnimatingScene}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
padding: '8px',
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #b35c1e 0%, #f5a623 100%)',
|
||||
boxShadow: '0 8px 16px rgba(179,92,30,0.35)',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #9c511a 0%, #e1911c 100%)',
|
||||
},
|
||||
'& .MuiButton-startIcon': {
|
||||
margin: 0,
|
||||
},
|
||||
'& .MuiButton-label': {
|
||||
display: 'none',
|
||||
},
|
||||
}}
|
||||
tooltipPlacement="left"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
@@ -325,6 +525,27 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
{isAnimatingScene && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(2px)',
|
||||
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||
borderRadius: '12px',
|
||||
color: '#fff',
|
||||
gap: 1,
|
||||
zIndex: 6,
|
||||
}}
|
||||
>
|
||||
<CircularProgress color="inherit" size={36} />
|
||||
<Typography variant="body2">Animating scene...</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Audio chip moved to right page */}
|
||||
@@ -375,7 +596,10 @@ const BookPages: React.FC<BookPagesProps> = ({
|
||||
'&:hover .chip-actions': { opacity: 1, pointerEvents: 'auto' },
|
||||
}}
|
||||
>
|
||||
<OutlineHoverActions onEdit={onOpenEditModal} onImprove={onOpenEditModal} />
|
||||
<OutlineHoverActions
|
||||
onEdit={onOpenEditModal}
|
||||
onImprove={onOpenEditModal}
|
||||
/>
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', pt: { xs: 1, md: 2 } }}>
|
||||
<Box className="chip-actions" sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 1.5, opacity: 0, pointerEvents: 'none', transition: 'opacity 0.2s ease' }}>
|
||||
<Chip
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@mui/material';
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Divider, CircularProgress } from '@mui/material';
|
||||
import { OperationButton } from '../../../shared/OperationButton';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import { storyWriterApi } from '../../../../services/storyWriterApi';
|
||||
|
||||
interface ImageEditModalProps {
|
||||
open: boolean;
|
||||
@@ -8,9 +12,82 @@ interface ImageEditModalProps {
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onRegenerate?: (prompt: string) => Promise<void>;
|
||||
imageProvider?: string | null;
|
||||
imageWidth?: number;
|
||||
imageHeight?: number;
|
||||
imageModel?: string | null;
|
||||
}
|
||||
|
||||
const ImageEditModal: React.FC<ImageEditModalProps> = ({ open, sceneNumber, value, onChange, onClose, onSave }) => {
|
||||
const ImageEditModal: React.FC<ImageEditModalProps> = ({
|
||||
open,
|
||||
sceneNumber,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
onRegenerate,
|
||||
imageProvider,
|
||||
imageWidth = 1024,
|
||||
imageHeight = 1024,
|
||||
imageModel,
|
||||
}) => {
|
||||
const [isRegenerating, setIsRegenerating] = React.useState(false);
|
||||
const [regenerateError, setRegenerateError] = React.useState<string | null>(null);
|
||||
const [isOptimizing, setIsOptimizing] = React.useState(false);
|
||||
const [optimizeError, setOptimizeError] = React.useState<string | null>(null);
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!onRegenerate || !value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegenerating(true);
|
||||
setRegenerateError(null);
|
||||
try {
|
||||
await onRegenerate(value.trim());
|
||||
// Optionally close modal after successful regeneration
|
||||
// onClose();
|
||||
} catch (err: any) {
|
||||
setRegenerateError(err?.response?.data?.detail || err?.message || 'Failed to regenerate image');
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptimize = async () => {
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOptimizing(true);
|
||||
setOptimizeError(null);
|
||||
try {
|
||||
const response = await storyWriterApi.optimizePrompt({
|
||||
text: value.trim(),
|
||||
mode: 'image', // Default to image mode for scene image prompts
|
||||
style: 'default', // Could be made configurable in the future
|
||||
});
|
||||
|
||||
if (response.success && response.optimized_prompt) {
|
||||
onChange(response.optimized_prompt);
|
||||
} else {
|
||||
throw new Error('Optimization returned no result');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.detail || err?.message || 'Failed to optimize prompt';
|
||||
setOptimizeError(errorMessage);
|
||||
console.error('Failed to optimize prompt:', err);
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine the model for cost estimation
|
||||
// Default to FLUX.1-Krea-dev for HuggingFace, or stability model
|
||||
const modelForEstimation = imageModel || (imageProvider === 'stability' ? 'stable-diffusion' : 'black-forest-labs/FLUX.1-Krea-dev');
|
||||
const providerForEstimation = imageProvider || 'huggingface';
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -44,7 +121,54 @@ const ImageEditModal: React.FC<ImageEditModalProps> = ({ open, sceneNumber, valu
|
||||
multiline
|
||||
minRows={5}
|
||||
fullWidth
|
||||
placeholder="Enter a detailed description of the scene image..."
|
||||
/>
|
||||
|
||||
{(regenerateError || optimizeError) && (
|
||||
<Box sx={{ color: 'error.main', fontSize: '0.875rem', mt: -1 }}>
|
||||
{regenerateError || optimizeError}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
{/* AI Prompt Optimizer */}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
startIcon={isOptimizing ? <CircularProgress size={16} /> : <AutoFixHighIcon />}
|
||||
onClick={handleOptimize}
|
||||
disabled={isOptimizing || !value.trim() || isRegenerating}
|
||||
sx={{ flex: 1, minWidth: '200px' }}
|
||||
>
|
||||
{isOptimizing ? 'Optimizing...' : 'AI Prompt Optimizer'}
|
||||
</Button>
|
||||
|
||||
{/* Regenerate Scene - Active with cost estimation */}
|
||||
{onRegenerate && (
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'stability',
|
||||
model: modelForEstimation,
|
||||
tokens_requested: 0,
|
||||
operation_type: 'image_generation',
|
||||
actual_provider_name: providerForEstimation,
|
||||
}}
|
||||
label="Regenerate Scene"
|
||||
variant="contained"
|
||||
size="medium"
|
||||
startIcon={<RefreshIcon />}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={handleRegenerate}
|
||||
disabled={isRegenerating || !value.trim()}
|
||||
loading={isRegenerating}
|
||||
sx={{ flex: 1, minWidth: '200px' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
|
||||
@@ -8,7 +8,10 @@ interface OutlineHoverActionsProps {
|
||||
onImprove: () => void;
|
||||
}
|
||||
|
||||
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({ onEdit, onImprove }) => {
|
||||
const OutlineHoverActions: React.FC<OutlineHoverActionsProps> = ({
|
||||
onEdit,
|
||||
onImprove,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
className="outline-actions"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../api/client';
|
||||
import { aiApiClient } from '../../../api/client';
|
||||
import { fetchMediaBlobUrl } from '../../../utils/fetchMediaBlobUrl';
|
||||
import { MultimediaSection } from '../components/MultimediaSection';
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
@@ -123,10 +124,13 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
const [pageDirection, setPageDirection] = useState(0);
|
||||
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [videoBlobUrls, setVideoBlobUrls] = useState<Map<number, string>>(new Map());
|
||||
const [videoLoadError, setVideoLoadError] = useState<Set<number>>(new Set());
|
||||
|
||||
// Get scenes and images from state
|
||||
const scenes = state.outlineScenes || [];
|
||||
const sceneImages = state.sceneImages || new Map<number, string>();
|
||||
const sceneAnimatedVideos = state.sceneAnimatedVideos || new Map<number, string>();
|
||||
const hasScenes = state.isOutlineStructured && scenes.length > 0;
|
||||
|
||||
// Split story content into sections mapped to scenes
|
||||
@@ -201,6 +205,10 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
}, []);
|
||||
|
||||
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
|
||||
const currentSceneAnimatedVideoUrl = sceneAnimatedVideos.get(currentSceneNumber) || null;
|
||||
const currentSceneAnimatedVideoBlobUrl = videoBlobUrls.get(currentSceneNumber) || null;
|
||||
const hasVideoLoadError = videoLoadError.has(currentSceneNumber);
|
||||
const showAnimatedVideo = Boolean(currentSceneAnimatedVideoBlobUrl);
|
||||
|
||||
// Reset image load error when page changes
|
||||
useEffect(() => {
|
||||
@@ -211,6 +219,60 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
});
|
||||
}, [currentSceneNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSceneAnimatedVideoUrl || hasVideoLoadError || currentSceneAnimatedVideoBlobUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadVideo = async () => {
|
||||
try {
|
||||
const videoPath = currentSceneAnimatedVideoUrl.startsWith('/')
|
||||
? currentSceneAnimatedVideoUrl
|
||||
: `/${currentSceneAnimatedVideoUrl}`;
|
||||
const blobUrl = await fetchMediaBlobUrl(videoPath);
|
||||
if (!blobUrl || cancelled) {
|
||||
if (!blobUrl) {
|
||||
setVideoLoadError((prev) => new Set(prev).add(currentSceneNumber));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoBlobUrls((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(currentSceneNumber);
|
||||
if (existing) {
|
||||
URL.revokeObjectURL(existing);
|
||||
}
|
||||
next.set(currentSceneNumber, blobUrl);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to load animated video:', err);
|
||||
setVideoLoadError((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(currentSceneNumber);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadVideo();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentSceneNumber, currentSceneAnimatedVideoUrl, currentSceneAnimatedVideoBlobUrl, hasVideoLoadError]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
videoBlobUrls.forEach((blob) => {
|
||||
URL.revokeObjectURL(blob);
|
||||
});
|
||||
};
|
||||
}, [videoBlobUrls]);
|
||||
|
||||
useEffect(() => {
|
||||
if (storySections.length > 0) {
|
||||
setCurrentPageIndex(0);
|
||||
@@ -502,7 +564,37 @@ const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{currentSceneImageFullUrl ? (
|
||||
{showAnimatedVideo ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.18), 0 4px 8px rgba(0, 0, 0, 0.12)',
|
||||
border: '3px solid rgba(120, 90, 60, 0.25)',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="video"
|
||||
src={currentSceneAnimatedVideoBlobUrl ?? undefined}
|
||||
poster={currentSceneImageFullUrl ?? undefined}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
controls
|
||||
playsInline
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
minHeight: '300px',
|
||||
maxHeight: '500px',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
) : currentSceneImageFullUrl ? (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
@@ -123,23 +123,38 @@ export const StoryWriter: React.FC = () => {
|
||||
setIsGeneratingVideo(true);
|
||||
|
||||
try {
|
||||
const imageUrls: string[] = [];
|
||||
const imageUrls: (string | null)[] = [];
|
||||
const audioUrls: string[] = [];
|
||||
const scenes = state.outlineScenes;
|
||||
|
||||
const videoUrls: (string | null)[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
|
||||
const imageUrl = state.sceneImages?.get(sceneNumber);
|
||||
const audioUrl = state.sceneAudio?.get(sceneNumber);
|
||||
const animatedVideoUrl = state.sceneAnimatedVideos?.get(sceneNumber);
|
||||
|
||||
if (imageUrl && audioUrl) {
|
||||
imageUrls.push(imageUrl);
|
||||
audioUrls.push(audioUrl);
|
||||
if (!audioUrl) {
|
||||
continue; // Skip scenes without audio
|
||||
}
|
||||
|
||||
// Prefer animated video if available, otherwise use image
|
||||
if (animatedVideoUrl) {
|
||||
videoUrls.push(animatedVideoUrl);
|
||||
imageUrls.push(null);
|
||||
} else if (imageUrl) {
|
||||
videoUrls.push(null);
|
||||
imageUrls.push(imageUrl);
|
||||
} else {
|
||||
continue; // Skip scenes without image or video
|
||||
}
|
||||
|
||||
audioUrls.push(audioUrl);
|
||||
}
|
||||
|
||||
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
|
||||
throw new Error('Number of images and audio files must match number of scenes');
|
||||
throw new Error('Number of images/videos and audio files must match number of scenes');
|
||||
}
|
||||
|
||||
// Switch to async flow so UI can poll progress messages
|
||||
@@ -147,6 +162,8 @@ export const StoryWriter: React.FC = () => {
|
||||
scenes: scenes,
|
||||
image_urls: imageUrls,
|
||||
audio_urls: audioUrls,
|
||||
video_urls: videoUrls.length > 0 ? videoUrls : undefined,
|
||||
ai_audio_urls: undefined, // TODO: Track AI audio separately in state
|
||||
story_title: state.storySetting || 'Story',
|
||||
fps: state.videoFps,
|
||||
transition_duration: state.videoTransitionDuration,
|
||||
|
||||
@@ -29,14 +29,30 @@ export const AudioPlayerList: React.FC<AudioPlayerListProps> = ({ scenes, sceneA
|
||||
for (const [sceneNumber, audioPath] of entries) {
|
||||
if (!audioPath) continue;
|
||||
try {
|
||||
const normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
|
||||
// Normalize path - ensure it starts with /api/story/audio/
|
||||
let normalizedPath = audioPath.startsWith('/') ? audioPath : `/${audioPath}`;
|
||||
|
||||
// If path doesn't include /api/story/audio/, add it
|
||||
if (!normalizedPath.includes('/api/story/audio/')) {
|
||||
// Extract filename from path
|
||||
const filename = audioPath.split('/').pop() || audioPath;
|
||||
normalizedPath = `/api/story/audio/${filename}`;
|
||||
}
|
||||
|
||||
const response = await aiApiClient.get(normalizedPath, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(response.data);
|
||||
blobEntries.push([sceneNumber, blobUrl]);
|
||||
} catch (err) {
|
||||
console.error('Failed to load audio blob:', err);
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to load audio blob for scene ${sceneNumber}:`, err);
|
||||
console.error(`Audio path was: ${audioPath}`);
|
||||
console.error(`Normalized path would be: ${audioPath.startsWith('/') ? audioPath : `/${audioPath}`}`);
|
||||
|
||||
// If auth error, log more details
|
||||
if (err?.response?.status === 401) {
|
||||
console.error(`Authentication failed for audio file. Make sure auth token is set.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +103,19 @@ export const AudioPlayerList: React.FC<AudioPlayerListProps> = ({ scenes, sceneA
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600, color: '#1A1611' }}>
|
||||
Scene {sceneNumber}: {scene.title || `Scene ${sceneNumber}`}
|
||||
</Typography>
|
||||
<audio
|
||||
controls
|
||||
src={blobUrl ? blobUrl : storyWriterApi.getAudioUrl(audioUrl)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
{blobUrl ? (
|
||||
<audio
|
||||
controls
|
||||
src={blobUrl}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
Loading audio...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,15 +2,14 @@ import React, { useState, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
LinearProgress,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import SmartDisplayIcon from '@mui/icons-material/SmartDisplay';
|
||||
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
|
||||
import { storyWriterApi } from '../../../services/storyWriterApi';
|
||||
import { triggerSubscriptionError } from '../../../api/client';
|
||||
import { OperationButton } from '../../shared/OperationButton';
|
||||
import SceneVideoApproval from './SceneVideoApproval';
|
||||
|
||||
// Simple logger for frontend
|
||||
@@ -94,14 +93,11 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
|
||||
setHdVideoMessage(`Generating HD video for Scene ${sceneNumber}...`);
|
||||
|
||||
try {
|
||||
const sceneImageUrl = state.sceneImages?.get(sceneNumber);
|
||||
|
||||
const result = await storyWriterApi.generateHdVideoScene({
|
||||
scene_number: sceneNumber,
|
||||
scene_data: scene,
|
||||
story_context: storyContext,
|
||||
all_scenes: scenes,
|
||||
scene_image_url: sceneImageUrl,
|
||||
provider: 'huggingface',
|
||||
model: 'tencent/HunyuanVideo',
|
||||
num_frames: 50,
|
||||
@@ -240,14 +236,11 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
|
||||
story_content: state.storyContent || '',
|
||||
};
|
||||
|
||||
const sceneImageUrl = state.sceneImages?.get(sceneNumber);
|
||||
|
||||
const result = await storyWriterApi.generateHdVideoScene({
|
||||
scene_number: sceneNumber,
|
||||
scene_data: scene,
|
||||
story_context: storyContext,
|
||||
all_scenes: scenes,
|
||||
scene_image_url: sceneImageUrl,
|
||||
provider: 'huggingface',
|
||||
model: 'tencent/HunyuanVideo',
|
||||
num_frames: 50,
|
||||
@@ -303,45 +296,30 @@ export const HdVideoSection: React.FC<HdVideoSectionProps> = ({ state, onError }
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Generate HD Animation with AI
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 1 }}>
|
||||
Upgrade this storyboard into a high‑definition AI animation using Hugging Face text‑to‑video models.
|
||||
Your draft was generated affordably (images + narration). This premium option uses an AI model to render motion.
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, fontWeight: 600 }}>
|
||||
Recommended models:
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" sx={{ display: 'block', mb: 1 }}>
|
||||
• tencent/HunyuanVideo<br />
|
||||
• Lightricks/LTX-Video<br />
|
||||
• Lightricks/LTX-Video-0.9.8-13B-distilled
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block', fontStyle: 'italic' }}>
|
||||
This will generate HD videos for each scene one at a time. You'll review and approve each scene before the next one is generated.
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SmartDisplayIcon />}
|
||||
onClick={handleGenerateHdVideo}
|
||||
disabled={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
|
||||
>
|
||||
{isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'
|
||||
? 'Generating HD Animation...'
|
||||
: 'Generate HD Animation with AI'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'video',
|
||||
model: 'tencent/HunyuanVideo',
|
||||
tokens_requested: 0,
|
||||
operation_type: 'video_generation',
|
||||
actual_provider_name: 'huggingface',
|
||||
}}
|
||||
label="Generate HD Animation with AI"
|
||||
variant="contained"
|
||||
startIcon={<SmartDisplayIcon />}
|
||||
showCost={true}
|
||||
checkOnHover={true}
|
||||
checkOnMount={false}
|
||||
onClick={handleGenerateHdVideo}
|
||||
disabled={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
|
||||
loading={isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'}
|
||||
tooltipPlacement="top"
|
||||
buttonProps={{
|
||||
children: isGeneratingHdVideo || state.hdVideoGenerationStatus === 'awaiting_approval'
|
||||
? 'Generating HD Animation...'
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
|
||||
{(isGeneratingHdVideo || state.hdVideoGenerationStatus === 'generating' || state.hdVideoGenerationStatus === 'awaiting_approval') && (
|
||||
<Box sx={{ mt: 2, p: 2, backgroundColor: '#FAF9F6', borderRadius: 1, border: '1px solid #E0DCD4' }}>
|
||||
|
||||
@@ -40,7 +40,19 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
|
||||
// Load video blob URL when storyVideo changes
|
||||
useEffect(() => {
|
||||
if (state.storyVideo) {
|
||||
fetchMediaBlobUrl(state.storyVideo).then(setVideoBlobUrl);
|
||||
fetchMediaBlobUrl(state.storyVideo)
|
||||
.then((blobUrl) => {
|
||||
if (blobUrl) {
|
||||
setVideoBlobUrl(blobUrl);
|
||||
} else {
|
||||
// File not found - clear the blob URL
|
||||
setVideoBlobUrl(null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to load video blob:', err);
|
||||
setVideoBlobUrl(null);
|
||||
});
|
||||
} else {
|
||||
if (videoBlobUrl) {
|
||||
URL.revokeObjectURL(videoBlobUrl);
|
||||
@@ -76,31 +88,50 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
|
||||
setVideoMessage('');
|
||||
|
||||
try {
|
||||
const imageUrls: string[] = [];
|
||||
const imageUrls: (string | null)[] = [];
|
||||
const audioUrls: string[] = [];
|
||||
const scenes = state.outlineScenes;
|
||||
|
||||
const videoUrls: (string | null)[] = [];
|
||||
const aiAudioUrls: (string | null)[] = [];
|
||||
|
||||
for (const scene of scenes) {
|
||||
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
|
||||
const imageUrl = state.sceneImages?.get(sceneNumber);
|
||||
const audioUrl = state.sceneAudio?.get(sceneNumber);
|
||||
const animatedVideoUrl = state.sceneAnimatedVideos?.get(sceneNumber);
|
||||
|
||||
if (imageUrl && audioUrl) {
|
||||
imageUrls.push(imageUrl);
|
||||
audioUrls.push(audioUrl);
|
||||
} else {
|
||||
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
|
||||
if (!audioUrl) {
|
||||
throw new Error(`Missing audio for scene ${sceneNumber}`);
|
||||
}
|
||||
|
||||
// Prefer animated video if available, otherwise use image
|
||||
if (animatedVideoUrl) {
|
||||
videoUrls.push(animatedVideoUrl);
|
||||
imageUrls.push(null);
|
||||
} else if (imageUrl) {
|
||||
videoUrls.push(null);
|
||||
imageUrls.push(imageUrl);
|
||||
} else {
|
||||
throw new Error(`Missing image or animated video for scene ${sceneNumber}`);
|
||||
}
|
||||
|
||||
audioUrls.push(audioUrl);
|
||||
// AI audio detection: check if URL contains 'ai' or 'wavespeed' (can be enhanced later)
|
||||
// For now, pass null and backend will use available audio
|
||||
aiAudioUrls.push(null);
|
||||
}
|
||||
|
||||
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
|
||||
throw new Error('Number of images and audio files must match number of scenes');
|
||||
throw new Error('Number of images/videos and audio files must match number of scenes');
|
||||
}
|
||||
|
||||
const start = await storyWriterApi.generateStoryVideoAsync({
|
||||
scenes: scenes,
|
||||
image_urls: imageUrls,
|
||||
audio_urls: audioUrls,
|
||||
video_urls: videoUrls.length > 0 ? videoUrls : undefined,
|
||||
ai_audio_urls: undefined, // TODO: Track AI audio separately in state
|
||||
story_title: state.storySetting || 'Story',
|
||||
fps: state.videoFps,
|
||||
transition_duration: state.videoTransitionDuration,
|
||||
@@ -122,7 +153,9 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
|
||||
if (!finalUrl) throw new Error('Video URL not found in result');
|
||||
state.setStoryVideo(finalUrl);
|
||||
const blobUrl = await fetchMediaBlobUrl(finalUrl);
|
||||
setVideoBlobUrl(blobUrl);
|
||||
if (blobUrl) {
|
||||
setVideoBlobUrl(blobUrl);
|
||||
}
|
||||
setVideoProgress(100);
|
||||
setVideoMessage('Video generation complete');
|
||||
state.setError(null);
|
||||
@@ -160,6 +193,10 @@ export const VideoSection: React.FC<VideoSectionProps> = ({ state, error, onErro
|
||||
const handleDownloadVideo = async () => {
|
||||
if (state.storyVideo) {
|
||||
const blobUrl = await fetchMediaBlobUrl(state.storyVideo);
|
||||
if (!blobUrl) {
|
||||
// File not found - skip download
|
||||
return;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = `story-video-${Date.now()}.mp4`;
|
||||
|
||||
273
frontend/src/components/shared/OperationButton.tsx
Normal file
273
frontend/src/components/shared/OperationButton.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
ButtonProps,
|
||||
Tooltip,
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { SxProps, Theme } from '@mui/material/styles';
|
||||
import { usePreflightCheck, UsePreflightCheckOptions } from '../../hooks/usePreflightCheck';
|
||||
import { PreflightOperation } from '../../services/billingService';
|
||||
|
||||
export interface OperationButtonProps {
|
||||
// Operation definition
|
||||
operation: PreflightOperation;
|
||||
|
||||
// Button configuration
|
||||
label: string; // Base label (e.g., "Generate HD Video")
|
||||
variant?: 'contained' | 'outlined' | 'text';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
color?: 'primary' | 'secondary' | 'success' | 'error';
|
||||
startIcon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
|
||||
// Pre-flight check behavior
|
||||
showCost?: boolean; // Show cost in label (default: true)
|
||||
checkOnHover?: boolean; // Check on hover (default: true)
|
||||
checkOnMount?: boolean; // Check on mount (default: false)
|
||||
|
||||
// Callbacks
|
||||
onClick: () => void;
|
||||
onPreflightResult?: (canProceed: boolean) => void;
|
||||
|
||||
// Customization
|
||||
disabled?: boolean; // Additional disabled state
|
||||
loading?: boolean; // Loading state override
|
||||
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
// Styling
|
||||
sx?: SxProps<Theme>;
|
||||
fullWidth?: boolean;
|
||||
|
||||
// Additional button props
|
||||
buttonProps?: Partial<ButtonProps>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable button component with pre-flight check and cost estimation.
|
||||
*
|
||||
* Features:
|
||||
* - Shows estimated cost in button label
|
||||
* - Performs pre-flight check on hover (debounced)
|
||||
* - Shows detailed tooltip with limits/remaining quota
|
||||
* - Disables button with messaging if blocked
|
||||
*/
|
||||
export const OperationButton: React.FC<OperationButtonProps> = ({
|
||||
operation,
|
||||
label,
|
||||
variant = 'contained',
|
||||
size = 'medium',
|
||||
color = 'primary',
|
||||
startIcon,
|
||||
endIcon,
|
||||
showCost = true,
|
||||
checkOnHover = true,
|
||||
checkOnMount = false,
|
||||
onClick,
|
||||
onPreflightResult,
|
||||
disabled: externalDisabled = false,
|
||||
loading: externalLoading = false,
|
||||
tooltipPlacement = 'top',
|
||||
sx,
|
||||
fullWidth = false,
|
||||
buttonProps = {},
|
||||
}) => {
|
||||
const preflightOptions: UsePreflightCheckOptions = {
|
||||
operation,
|
||||
enabled: checkOnHover || checkOnMount,
|
||||
debounceMs: 300,
|
||||
cacheTtl: 5000,
|
||||
};
|
||||
|
||||
const {
|
||||
canProceed,
|
||||
estimatedCost,
|
||||
limitInfo,
|
||||
loading: preflightLoading,
|
||||
error: preflightError,
|
||||
checkOnHover: triggerCheckOnHover,
|
||||
checkNow: triggerCheckNow,
|
||||
} = usePreflightCheck(preflightOptions);
|
||||
|
||||
// Check on mount if requested
|
||||
React.useEffect(() => {
|
||||
if (checkOnMount) {
|
||||
triggerCheckNow();
|
||||
}
|
||||
}, [checkOnMount, triggerCheckNow]);
|
||||
|
||||
// Notify parent of pre-flight result changes
|
||||
React.useEffect(() => {
|
||||
if (onPreflightResult) {
|
||||
onPreflightResult(canProceed);
|
||||
}
|
||||
}, [canProceed, onPreflightResult]);
|
||||
|
||||
// Format cost as currency
|
||||
const formattedCost = useMemo(() => {
|
||||
if (!showCost || estimatedCost === 0) {
|
||||
return null;
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(estimatedCost);
|
||||
}, [estimatedCost, showCost]);
|
||||
|
||||
// Build button label with cost
|
||||
const buttonLabel = useMemo(() => {
|
||||
if (formattedCost) {
|
||||
return `${label} ${formattedCost}`;
|
||||
}
|
||||
return label;
|
||||
}, [label, formattedCost]);
|
||||
|
||||
// Determine if button should be disabled
|
||||
const isDisabled = useMemo(() => {
|
||||
return externalDisabled || externalLoading || preflightLoading || !canProceed;
|
||||
}, [externalDisabled, externalLoading, preflightLoading, canProceed]);
|
||||
|
||||
// Build tooltip content
|
||||
const tooltipContent = useMemo(() => {
|
||||
const content: React.ReactNode[] = [];
|
||||
|
||||
if (preflightLoading) {
|
||||
content.push(
|
||||
<Typography key="loading" variant="body2" sx={{ mb: 1 }}>
|
||||
Checking limits...
|
||||
</Typography>
|
||||
);
|
||||
} else if (preflightError) {
|
||||
content.push(
|
||||
<Typography key="error" variant="body2" sx={{ mb: 1, color: 'error.main', fontWeight: 600 }}>
|
||||
{preflightError}
|
||||
</Typography>
|
||||
);
|
||||
} else if (limitInfo) {
|
||||
const { current_usage, limit, remaining } = limitInfo;
|
||||
const isUnlimited = limit === 0 || remaining === Infinity;
|
||||
|
||||
content.push(
|
||||
<Box key="limits" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{canProceed ? '✅ Operation Allowed' : '❌ Operation Blocked'}
|
||||
</Typography>
|
||||
{isUnlimited ? (
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
Usage: {current_usage} / Unlimited
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
Usage: {current_usage} / {limit} ({remaining} remaining)
|
||||
</Typography>
|
||||
)}
|
||||
{formattedCost && (
|
||||
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, fontWeight: 600 }}>
|
||||
Estimated Cost: {formattedCost}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (preflightError && !canProceed) {
|
||||
content.push(
|
||||
<Typography key="message" variant="caption" sx={{ display: 'block', color: 'error.main' }}>
|
||||
{preflightError}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return content.length > 0 ? <Box sx={{ p: 0.5 }}>{content}</Box> : null;
|
||||
}, [canProceed, estimatedCost, formattedCost, limitInfo, preflightError, preflightLoading]);
|
||||
|
||||
// Handle hover
|
||||
const handleMouseEnter = () => {
|
||||
if (checkOnHover) {
|
||||
triggerCheckOnHover();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click
|
||||
const handleClick = () => {
|
||||
if (!isDisabled && canProceed) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine button color based on state
|
||||
const buttonColor = useMemo(() => {
|
||||
if (!canProceed) {
|
||||
return 'error';
|
||||
}
|
||||
return color;
|
||||
}, [canProceed, color]);
|
||||
|
||||
// Determine if we should show loading spinner
|
||||
const showLoading = externalLoading || (preflightLoading && checkOnMount);
|
||||
|
||||
// Custom label override for loading state
|
||||
const displayLabel = useMemo(() => {
|
||||
if (externalLoading && buttonProps?.children) {
|
||||
return buttonProps.children;
|
||||
}
|
||||
if (showLoading && !externalLoading) {
|
||||
return 'Checking...';
|
||||
}
|
||||
if (!canProceed && preflightError) {
|
||||
return preflightError;
|
||||
}
|
||||
return buttonLabel;
|
||||
}, [externalLoading, showLoading, canProceed, preflightError, buttonLabel, buttonProps?.children]);
|
||||
|
||||
// Build button with icon
|
||||
const button = (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
color={buttonColor}
|
||||
startIcon={
|
||||
showLoading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : !canProceed ? (
|
||||
<WarningIcon fontSize="small" />
|
||||
) : (
|
||||
startIcon
|
||||
)
|
||||
}
|
||||
endIcon={endIcon}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
fullWidth={fullWidth}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
sx={sx}
|
||||
{...buttonProps}
|
||||
>
|
||||
{displayLabel}
|
||||
</Button>
|
||||
);
|
||||
|
||||
// Wrap with tooltip if we have content
|
||||
if (tooltipContent || checkOnHover) {
|
||||
return (
|
||||
<Tooltip
|
||||
title={tooltipContent || 'Hover to check limits'}
|
||||
arrow
|
||||
placement={tooltipPlacement}
|
||||
onOpen={handleMouseEnter}
|
||||
>
|
||||
<span style={{ display: 'inline-flex' }}>
|
||||
{button}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
257
frontend/src/hooks/usePreflightCheck.ts
Normal file
257
frontend/src/hooks/usePreflightCheck.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
checkPreflight,
|
||||
PreflightOperation,
|
||||
PreflightCheckResponse,
|
||||
PreflightLimitInfo,
|
||||
} from '../services/billingService';
|
||||
|
||||
export interface UsePreflightCheckOptions {
|
||||
operation: PreflightOperation;
|
||||
enabled?: boolean; // Whether to perform check on hover
|
||||
debounceMs?: number; // Debounce delay (default: 300ms)
|
||||
cacheTtl?: number; // Cache TTL in ms (default: 5000ms)
|
||||
}
|
||||
|
||||
export interface UsePreflightCheckResult {
|
||||
canProceed: boolean;
|
||||
estimatedCost: number;
|
||||
limitInfo: PreflightLimitInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
checkOnHover: () => void;
|
||||
checkNow: () => void; // Immediate check
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: PreflightCheckResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for pre-flight checking operations with cost estimation.
|
||||
*
|
||||
* Features:
|
||||
* - Debounced hover checks (300ms default)
|
||||
* - In-memory caching (5s default TTL)
|
||||
* - Request cancellation on unmount
|
||||
*/
|
||||
export const usePreflightCheck = (
|
||||
options: UsePreflightCheckOptions
|
||||
): UsePreflightCheckResult => {
|
||||
const {
|
||||
operation,
|
||||
enabled = true,
|
||||
debounceMs = 300,
|
||||
cacheTtl = 5000,
|
||||
} = options;
|
||||
|
||||
const [canProceed, setCanProceed] = useState<boolean>(true);
|
||||
const [estimatedCost, setEstimatedCost] = useState<number>(0);
|
||||
const [limitInfo, setLimitInfo] = useState<PreflightLimitInfo | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cache for pre-flight check results
|
||||
const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
|
||||
|
||||
// Debounce timer ref
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Abort controller for request cancellation
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Generate cache key from operation
|
||||
const getCacheKey = useCallback(() => {
|
||||
return JSON.stringify(operation);
|
||||
}, [operation]);
|
||||
|
||||
// Check if cached result is still valid
|
||||
const getCachedResult = useCallback((): PreflightCheckResponse | null => {
|
||||
const cacheKey = getCacheKey();
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.timestamp;
|
||||
if (age < cacheTtl) {
|
||||
return cached.data;
|
||||
}
|
||||
// Cache expired, remove it
|
||||
cacheRef.current.delete(cacheKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [getCacheKey, cacheTtl]);
|
||||
|
||||
// Store result in cache
|
||||
const setCache = useCallback((data: PreflightCheckResponse) => {
|
||||
const cacheKey = getCacheKey();
|
||||
cacheRef.current.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [getCacheKey]);
|
||||
|
||||
// Perform actual pre-flight check
|
||||
const performCheck = useCallback(async (): Promise<void> => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedResult();
|
||||
if (cached) {
|
||||
updateState(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
const currentAbortController = abortControllerRef.current;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await checkPreflight(operation);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (currentAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
setCache(response);
|
||||
|
||||
// Update state
|
||||
updateState(response);
|
||||
} catch (err: any) {
|
||||
// Check if request was cancelled
|
||||
if (currentAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err?.message || 'Pre-flight check failed';
|
||||
setError(errorMessage);
|
||||
setCanProceed(false);
|
||||
setEstimatedCost(0);
|
||||
setLimitInfo(null);
|
||||
} finally {
|
||||
if (!currentAbortController.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [operation, enabled, getCachedResult, setCache]);
|
||||
|
||||
// Update state from response
|
||||
const updateState = useCallback((response: PreflightCheckResponse) => {
|
||||
setCanProceed(response.can_proceed);
|
||||
setEstimatedCost(response.estimated_cost);
|
||||
|
||||
// Get limit info from first operation (for single operation checks)
|
||||
const firstOp = response.operations[0];
|
||||
if (firstOp) {
|
||||
setLimitInfo(firstOp.limit_info);
|
||||
if (!response.can_proceed && firstOp.message) {
|
||||
setError(firstOp.message);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
} else {
|
||||
setLimitInfo(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Debounced check for hover events
|
||||
const checkOnHover = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Check cache first (no debounce for cache hits)
|
||||
const cached = getCachedResult();
|
||||
if (cached) {
|
||||
updateState(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce the actual API call
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performCheck();
|
||||
}, debounceMs);
|
||||
}, [enabled, debounceMs, getCachedResult, updateState, performCheck]);
|
||||
|
||||
// Immediate check (no debounce)
|
||||
const checkNow = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending debounced check
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
performCheck();
|
||||
}, [enabled, performCheck]);
|
||||
|
||||
// Reset state
|
||||
const reset = useCallback(() => {
|
||||
setCanProceed(true);
|
||||
setEstimatedCost(0);
|
||||
setLimitInfo(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
canProceed,
|
||||
estimatedCost,
|
||||
limitInfo,
|
||||
loading,
|
||||
error,
|
||||
checkOnHover,
|
||||
checkNow,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
StoryFullGenerationResponse,
|
||||
} from '../services/storyWriterApi';
|
||||
|
||||
export interface SceneAnimationResume {
|
||||
predictionId: string;
|
||||
duration: 5 | 10;
|
||||
message?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface StoryWriterState {
|
||||
// Story parameters (Setup phase)
|
||||
persona: string;
|
||||
@@ -52,6 +59,8 @@ export interface StoryWriterState {
|
||||
sceneAudio: Map<number, string> | null; // Generated audio URLs by scene number
|
||||
storyVideo: string | null; // Generated video URL
|
||||
sceneHdVideos: Map<number, string> | null; // Approved HD video URLs by scene number
|
||||
sceneAnimatedVideos: Map<number, string> | null; // Animated scene preview videos
|
||||
sceneAnimationResumables: Map<number, SceneAnimationResume> | null; // Pending resume info per scene
|
||||
hdVideoGenerationStatus: 'idle' | 'generating' | 'awaiting_approval' | 'completed' | 'paused';
|
||||
currentHdSceneIndex: number; // Which scene is currently being generated/reviewed
|
||||
|
||||
@@ -104,6 +113,8 @@ const DEFAULT_STATE: Partial<StoryWriterState> = {
|
||||
sceneAudio: null,
|
||||
storyVideo: null,
|
||||
sceneHdVideos: null,
|
||||
sceneAnimatedVideos: null,
|
||||
sceneAnimationResumables: null,
|
||||
hdVideoGenerationStatus: 'idle',
|
||||
currentHdSceneIndex: 0,
|
||||
currentTaskId: null,
|
||||
@@ -148,6 +159,8 @@ export const useStoryWriterState = () => {
|
||||
sceneImages: parsed.sceneImages ? new Map(parsed.sceneImages) : null,
|
||||
sceneAudio: parsed.sceneAudio ? new Map(parsed.sceneAudio) : null,
|
||||
sceneHdVideos: parsed.sceneHdVideos ? new Map(parsed.sceneHdVideos) : null,
|
||||
sceneAnimatedVideos: parsed.sceneAnimatedVideos ? new Map(parsed.sceneAnimatedVideos) : null,
|
||||
sceneAnimationResumables: parsed.sceneAnimationResumables ? new Map(parsed.sceneAnimationResumables) : null,
|
||||
};
|
||||
|
||||
return restoredState as StoryWriterState;
|
||||
@@ -193,6 +206,12 @@ export const useStoryWriterState = () => {
|
||||
sceneImages: persistableState.sceneImages ? Array.from(persistableState.sceneImages.entries()) : null,
|
||||
sceneAudio: persistableState.sceneAudio ? Array.from(persistableState.sceneAudio.entries()) : null,
|
||||
sceneHdVideos: persistableState.sceneHdVideos ? Array.from(persistableState.sceneHdVideos.entries()) : null,
|
||||
sceneAnimatedVideos: persistableState.sceneAnimatedVideos
|
||||
? Array.from(persistableState.sceneAnimatedVideos.entries())
|
||||
: null,
|
||||
sceneAnimationResumables: persistableState.sceneAnimationResumables
|
||||
? Array.from(persistableState.sceneAnimationResumables.entries())
|
||||
: null,
|
||||
};
|
||||
|
||||
localStorage.setItem('story_writer_state', JSON.stringify(serializableState));
|
||||
@@ -337,6 +356,14 @@ export const useStoryWriterState = () => {
|
||||
setState((prev) => ({ ...prev, sceneImages: images }));
|
||||
}, []);
|
||||
|
||||
const setSceneAnimatedVideos = useCallback((videos: Map<number, string> | null) => {
|
||||
setState((prev) => ({ ...prev, sceneAnimatedVideos: videos }));
|
||||
}, []);
|
||||
|
||||
const setSceneAnimationResumables = useCallback((resumables: Map<number, SceneAnimationResume> | null) => {
|
||||
setState((prev) => ({ ...prev, sceneAnimationResumables: resumables }));
|
||||
}, []);
|
||||
|
||||
const setSceneAudio = useCallback((audio: Map<number, string> | null) => {
|
||||
setState((prev) => ({ ...prev, sceneAudio: audio }));
|
||||
}, []);
|
||||
@@ -471,6 +498,8 @@ export const useStoryWriterState = () => {
|
||||
setSceneAudio,
|
||||
setStoryVideo,
|
||||
setSceneHdVideos,
|
||||
setSceneAnimatedVideos,
|
||||
setSceneAnimationResumables,
|
||||
setHdVideoGenerationStatus,
|
||||
setCurrentHdSceneIndex,
|
||||
setCurrentTaskId,
|
||||
|
||||
@@ -587,6 +587,127 @@ export const formatCurrency = (amount: number): string => {
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Pre-flight check interfaces
|
||||
export interface PreflightOperation {
|
||||
provider: string;
|
||||
model?: string;
|
||||
tokens_requested?: number;
|
||||
operation_type: string;
|
||||
actual_provider_name?: string;
|
||||
}
|
||||
|
||||
export interface PreflightLimitInfo {
|
||||
current_usage: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface PreflightOperationResult {
|
||||
provider: string;
|
||||
operation_type: string;
|
||||
cost: number;
|
||||
allowed: boolean;
|
||||
limit_info: PreflightLimitInfo | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface PreflightCheckResponse {
|
||||
can_proceed: boolean;
|
||||
estimated_cost: number;
|
||||
operations: PreflightOperationResult[];
|
||||
total_cost: number;
|
||||
usage_summary: {
|
||||
current_calls: number;
|
||||
limit: number;
|
||||
remaining: number;
|
||||
} | null;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check pre-flight validation for a single operation.
|
||||
* Returns cost estimation, limits check, and usage information.
|
||||
*/
|
||||
export const checkPreflight = async (
|
||||
operation: PreflightOperation
|
||||
): Promise<PreflightCheckResponse> => {
|
||||
try {
|
||||
const response = await billingAPI.post<{ success: boolean; data: PreflightCheckResponse }>(
|
||||
'/preflight-check',
|
||||
{
|
||||
operations: [operation]
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Pre-flight check failed');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
console.error('[BillingService] Pre-flight check error:', error);
|
||||
|
||||
// Return a safe default response on error
|
||||
return {
|
||||
can_proceed: false,
|
||||
estimated_cost: 0,
|
||||
operations: [{
|
||||
provider: operation.provider,
|
||||
operation_type: operation.operation_type,
|
||||
cost: 0,
|
||||
allowed: false,
|
||||
limit_info: null,
|
||||
message: error?.response?.data?.detail || 'Pre-flight check failed'
|
||||
}],
|
||||
total_cost: 0,
|
||||
usage_summary: null,
|
||||
cached: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check pre-flight validation for multiple operations in a single request.
|
||||
* Useful for pages with many buttons to reduce API calls.
|
||||
*/
|
||||
export const checkPreflightBatch = async (
|
||||
operations: PreflightOperation[]
|
||||
): Promise<PreflightCheckResponse> => {
|
||||
try {
|
||||
const response = await billingAPI.post<{ success: boolean; data: PreflightCheckResponse }>(
|
||||
'/preflight-check',
|
||||
{
|
||||
operations
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('Pre-flight check failed');
|
||||
}
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
console.error('[BillingService] Pre-flight batch check error:', error);
|
||||
|
||||
// Return a safe default response on error
|
||||
return {
|
||||
can_proceed: false,
|
||||
estimated_cost: 0,
|
||||
operations: operations.map(op => ({
|
||||
provider: op.provider,
|
||||
operation_type: op.operation_type,
|
||||
cost: 0,
|
||||
allowed: false,
|
||||
limit_info: null,
|
||||
message: error?.response?.data?.detail || 'Pre-flight check failed'
|
||||
})),
|
||||
total_cost: 0,
|
||||
usage_summary: null,
|
||||
cached: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
@@ -204,8 +204,10 @@ export interface StoryAudioGenerationResponse {
|
||||
|
||||
export interface StoryVideoGenerationRequest {
|
||||
scenes: StoryScene[];
|
||||
image_urls: string[];
|
||||
image_urls: (string | null)[];
|
||||
audio_urls: string[];
|
||||
video_urls?: (string | null)[] | null;
|
||||
ai_audio_urls?: (string | null)[] | null;
|
||||
story_title?: string;
|
||||
fps?: number;
|
||||
transition_duration?: number;
|
||||
@@ -227,6 +229,38 @@ export interface StoryVideoGenerationResponse {
|
||||
task_id?: string;
|
||||
}
|
||||
|
||||
export interface AnimateSceneRequest {
|
||||
scene_number: number;
|
||||
scene_data: StoryScene;
|
||||
story_context: Record<string, any>;
|
||||
image_url: string;
|
||||
duration?: 5 | 10;
|
||||
}
|
||||
|
||||
export interface AnimateSceneVoiceoverRequest extends AnimateSceneRequest {
|
||||
audio_url: string;
|
||||
resolution?: '480p' | '720p';
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
export interface AnimateSceneResponse {
|
||||
success: boolean;
|
||||
scene_number: number;
|
||||
video_filename: string;
|
||||
video_url: string;
|
||||
duration: number;
|
||||
cost: number;
|
||||
prompt_used: string;
|
||||
provider: string;
|
||||
prediction_id?: string;
|
||||
}
|
||||
|
||||
export interface ResumeAnimateSceneRequest {
|
||||
prediction_id: string;
|
||||
scene_number: number;
|
||||
duration?: 5 | 10;
|
||||
}
|
||||
|
||||
class StoryWriterApi {
|
||||
/**
|
||||
* Generate 3 story setup options from a user's story idea
|
||||
@@ -373,20 +407,63 @@ class StoryWriterApi {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a single scene image into a short video preview
|
||||
*/
|
||||
async animateScene(request: AnimateSceneRequest): Promise<AnimateSceneResponse> {
|
||||
const response = await aiApiClient.post<AnimateSceneResponse>(
|
||||
"/api/story/animate-scene-preview",
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate a scene image using WaveSpeed InfiniteTalk with voiceover (async)
|
||||
* Returns task_id for polling since InfiniteTalk can take up to 10 minutes.
|
||||
*/
|
||||
async animateSceneVoiceover(request: AnimateSceneVoiceoverRequest): Promise<{ task_id: string; status: string; message: string }> {
|
||||
const response = await aiApiClient.post<{ task_id: string; status: string; message: string }>(
|
||||
"/api/story/animate-scene-voiceover",
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a timed-out scene animation download using the prediction id
|
||||
*/
|
||||
async resumeAnimateScene(request: ResumeAnimateSceneRequest): Promise<AnimateSceneResponse> {
|
||||
const response = await aiApiClient.post<AnimateSceneResponse>(
|
||||
"/api/story/animate-scene-resume",
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private buildAbsoluteUrl(path: string): string {
|
||||
if (!path) return path;
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
const baseURL = aiApiClient.defaults.baseURL || '';
|
||||
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
||||
return `${cleanBaseURL}${cleanPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image URL for a scene image
|
||||
*/
|
||||
getImageUrl(imageUrl: string): string {
|
||||
// If imageUrl is already a full URL, return it as-is
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return imageUrl;
|
||||
return this.buildAbsoluteUrl(imageUrl);
|
||||
}
|
||||
// Otherwise, prepend the base URL
|
||||
const baseURL = aiApiClient.defaults.baseURL || '';
|
||||
// Remove trailing slash from baseURL if present, and leading slash from imageUrl if present
|
||||
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
|
||||
const cleanImageUrl = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
|
||||
return `${cleanBaseURL}${cleanImageUrl}`;
|
||||
|
||||
/**
|
||||
* Convert any relative media URL to absolute
|
||||
*/
|
||||
getMediaUrl(path: string): string {
|
||||
return this.buildAbsoluteUrl(path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,6 +477,165 @@ class StoryWriterApi {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize an image prompt using WaveSpeed prompt optimizer
|
||||
*/
|
||||
async optimizePrompt(request: {
|
||||
text: string;
|
||||
mode?: 'image' | 'video';
|
||||
style?: 'default' | 'artistic' | 'photographic' | 'technical' | 'anime' | 'realistic';
|
||||
image?: string;
|
||||
}): Promise<{ optimized_prompt: string; success: boolean }> {
|
||||
const response = await aiApiClient.post<{ optimized_prompt: string; success: boolean }>(
|
||||
"/api/story/optimize-prompt",
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate a scene image using a direct prompt (no AI prompt generation)
|
||||
*/
|
||||
async regenerateSceneImage(request: {
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
prompt: string;
|
||||
provider?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
model?: string;
|
||||
}): Promise<{
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
image_filename: string;
|
||||
image_url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
provider: string;
|
||||
model?: string;
|
||||
seed?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const response = await aiApiClient.post<{
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
image_filename: string;
|
||||
image_url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
provider: string;
|
||||
model?: string;
|
||||
seed?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>(
|
||||
"/api/story/regenerate-images",
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI audio for a single scene using WaveSpeed Minimax Speech 02 HD
|
||||
*/
|
||||
async generateAIAudio(request: {
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
text: string;
|
||||
voice_id?: string;
|
||||
speed?: number;
|
||||
volume?: number;
|
||||
pitch?: number;
|
||||
emotion?: string;
|
||||
}): Promise<{
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
audio_filename: string;
|
||||
audio_url: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
voice_id: string;
|
||||
text_length: number;
|
||||
file_size: number;
|
||||
cost: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const response = await aiApiClient.post<{
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
audio_filename: string;
|
||||
audio_url: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
voice_id: string;
|
||||
text_length: number;
|
||||
file_size: number;
|
||||
cost: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}>(
|
||||
"/api/story/generate-ai-audio",
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate free audio for a single scene using gTTS
|
||||
*/
|
||||
async generateFreeAudio(request: {
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
text: string;
|
||||
provider?: string;
|
||||
lang?: string;
|
||||
slow?: boolean;
|
||||
rate?: number;
|
||||
}): Promise<{
|
||||
scene_number: number;
|
||||
scene_title: string;
|
||||
audio_filename: string;
|
||||
audio_url: string;
|
||||
provider: string;
|
||||
file_size: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
// Use existing generateSceneAudio endpoint but for a single scene
|
||||
const response = await aiApiClient.post<StoryAudioGenerationResponse>(
|
||||
"/api/story/generate-audio",
|
||||
{
|
||||
scenes: [{
|
||||
scene_number: request.scene_number,
|
||||
title: request.scene_title,
|
||||
audio_narration: request.text,
|
||||
}],
|
||||
provider: request.provider || 'gtts',
|
||||
lang: request.lang || 'en',
|
||||
slow: request.slow || false,
|
||||
rate: request.rate || 150,
|
||||
}
|
||||
);
|
||||
const result = response.data;
|
||||
if (result.success && result.audio_files && result.audio_files.length > 0) {
|
||||
const audio = result.audio_files[0];
|
||||
return {
|
||||
scene_number: audio.scene_number,
|
||||
scene_title: audio.scene_title,
|
||||
audio_filename: audio.audio_filename,
|
||||
audio_url: audio.audio_url,
|
||||
provider: audio.provider,
|
||||
file_size: audio.file_size,
|
||||
success: true,
|
||||
error: audio.error,
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.audio_files?.[0]?.error || 'Failed to generate audio');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audio URL for a scene audio file
|
||||
*/
|
||||
@@ -496,7 +732,6 @@ class StoryWriterApi {
|
||||
scene_data: StoryScene;
|
||||
story_context: Record<string, any>;
|
||||
all_scenes: StoryScene[];
|
||||
scene_image_url?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
num_frames?: number;
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { aiApiClient } from "../api/client";
|
||||
|
||||
export async function fetchMediaBlobUrl(pathOrUrl: string): Promise<string> {
|
||||
const rel = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
|
||||
const res = await aiApiClient.get(rel, { responseType: "blob" });
|
||||
return URL.createObjectURL(res.data);
|
||||
export async function fetchMediaBlobUrl(pathOrUrl: string): Promise<string | null> {
|
||||
try {
|
||||
const rel = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
|
||||
const res = await aiApiClient.get(rel, { responseType: "blob" });
|
||||
return URL.createObjectURL(res.data);
|
||||
} catch (err: any) {
|
||||
// Gracefully handle 404s and other errors - file might not exist or was regenerated
|
||||
if (err?.response?.status === 404) {
|
||||
console.warn(`Media file not found (404): ${pathOrUrl}`);
|
||||
return null;
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user