import * as React from 'react';
import { Avatar, Voice, VideoScene, VideoProject, AIGCVideo, AIGCSettings, MediaSource, ScriptContent, ScriptVersion } from '../types';
import { Icon } from './ui/Icon';
import { Tabs } from './ui/Tabs';
import { polishScript } from '../services/geminiService';
// --- MOCK DATA ---
const mockAvatars: Avatar[] = [
{ id: 'av1', name: 'Robotix', imageUrl: 'https://api.dicebear.com/8.x/bottts-neutral/svg?seed=robotix' },
{ id: 'av2', name: 'Cyber', imageUrl: 'https://api.dicebear.com/8.x/bottts-neutral/svg?seed=cyber' },
{ id: 'av3', name: 'Gizmo', imageUrl: 'https://api.dicebear.com/8.x/bottts-neutral/svg?seed=gizmo' },
];
const mockVoices: Voice[] = [ { id: 'vo1', name: 'Alloy', accent: 'Neutral' }, { id: 'vo2', name: 'Echo', accent: 'Warm' }, { id: 'vo3', name: 'Fable', accent: 'Professional' }];
const mockBackgrounds = {
colors: ['#111827', '#1f2937', '#374151', '#4b5563', '#6b7280', '#9ca3af', '#d1d5db', '#f3f4f6'],
images: [
{ type: 'url', value: 'https://picsum.photos/seed/bg1/800/450' },
{ type: 'url', value: 'https://picsum.photos/seed/bg2/800/450' },
{ type: 'url', value: 'https://picsum.photos/seed/bg3/800/450' },
{ type: 'url', value: 'https://picsum.photos/seed/bg4/800/450' }
] as MediaSource[]
};
const initialScriptVersionId = `v-init-${Date.now()}`;
const initialProject: VideoProject = {
id: `proj-${Date.now()}`,
name: 'My First Video',
aspectRatio: '16:9',
scenes: [{
id: `scene-${Date.now()}`,
script: {
versions: [{ id: initialScriptVersionId, text: 'Welcome to our presentation!' }],
selectedVersionId: initialScriptVersionId
},
avatarId: 'av1',
voiceId: 'vo1',
background: { type: 'color', value: '#1f2937' },
avatarPosition: { x: 50, y: 10 },
avatarScale: 1,
}]
};
// --- Child Components ---
const GenerationProgress: React.FC<{ progress: number; totalVideos: number; currentVideo: number }> = ({ progress, totalVideos, currentVideo }) => (
Generating Videos...
{`Processing video ${currentVideo} of ${totalVideos}. This may take a moment.`}
);
const VideoCanvas: React.FC<{
scene: VideoScene;
aspectRatio: '16:9' | '9:16';
onAvatarMove: (pos: { x: number, y: number }) => void;
onAvatarScale: (newScale: number) => void;
}> = ({ scene, aspectRatio, onAvatarMove, onAvatarScale }) => {
const canvasRef = React.useRef(null);
const avatarRef = React.useRef(null);
// Interaction state
const [isSelected, setIsSelected] = React.useState(false);
const [interactionState, setInteractionState] = React.useState<'idle' | 'dragging' | 'resizing'>('idle');
// Refs to store start positions for calculations
const interactionStartRef = React.useRef({
dragOffsetX: 0,
dragOffsetY: 0,
resizeStartX: 0,
resizeStartWidth: 0,
resizeStartScale: 1,
});
const activeAvatar = mockAvatars.find(a => a.id === scene.avatarId);
// Mouse Down on Avatar -> start dragging
const handleAvatarMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!avatarRef.current || !canvasRef.current) return;
setIsSelected(true);
setInteractionState('dragging');
const avatarRect = avatarRef.current.getBoundingClientRect();
interactionStartRef.current = {
...interactionStartRef.current,
dragOffsetX: e.clientX - avatarRect.left,
dragOffsetY: e.clientY - avatarRect.top,
};
};
// Mouse Down on Resize Handle -> start resizing
const handleResizeMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!avatarRef.current) return;
setInteractionState('resizing');
interactionStartRef.current = {
...interactionStartRef.current,
resizeStartX: e.clientX,
resizeStartWidth: avatarRef.current.clientWidth,
resizeStartScale: scene.avatarScale,
};
};
// Effect for global mouse move and mouse up events
React.useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (interactionState === 'idle' || !canvasRef.current) return;
e.preventDefault();
if (interactionState === 'dragging') {
if (!avatarRef.current) return;
const canvasRect = canvasRef.current.getBoundingClientRect();
const newAvatarLeft = e.clientX - canvasRect.left - interactionStartRef.current.dragOffsetX;
const newAvatarTop = e.clientY - canvasRect.top - interactionStartRef.current.dragOffsetY;
const avatarWidth = avatarRef.current.offsetWidth;
const avatarHeight = avatarRef.current.offsetHeight;
const newXPercent = ((newAvatarLeft + avatarWidth / 2) / canvasRect.width) * 100;
const newYFromBottom = canvasRect.height - newAvatarTop - avatarHeight;
const newYPercent = (newYFromBottom / canvasRect.height) * 100;
const clampedX = Math.max((avatarWidth / 2 / canvasRect.width) * 100, Math.min(100 - (avatarWidth / 2 / canvasRect.width) * 100, newXPercent));
const clampedY = Math.max(0, Math.min(100 - (avatarHeight / canvasRect.height) * 100, newYPercent));
onAvatarMove({ x: clampedX, y: clampedY });
}
if (interactionState === 'resizing') {
const deltaX = e.clientX - interactionStartRef.current.resizeStartX;
const newWidth = interactionStartRef.current.resizeStartWidth + deltaX;
const baseWidth = interactionStartRef.current.resizeStartWidth / interactionStartRef.current.resizeStartScale;
if (baseWidth === 0) return;
const newScale = newWidth / baseWidth;
onAvatarScale(Math.max(0.2, Math.min(3.0, newScale)));
}
};
const handleMouseUp = () => {
setInteractionState('idle');
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [interactionState, onAvatarMove, onAvatarScale]);
// Deselect avatar when clicking on canvas background
const handleCanvasMouseDown = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
setIsSelected(false);
}
};
const isVideoBg = scene.background.type === 'video';
const backgroundStyle = scene.background.type !== 'video' ? {
background: scene.background.type === 'color' ? scene.background.value : `url(${scene.background.value}) center/cover`
} : {};
return (
{isVideoBg && (
)}
{activeAvatar &&

{isSelected && (
<>
>
)}
}
);
};
// --- Main Component ---
interface VideoCreatorProps {
aigcSettings: AIGCSettings;
onUpdateAIGCSettings: (newSettings: AIGCSettings) => void;
onVideosGenerated: (videos: AIGCVideo[]) => void;
}
const VideoCreator: React.FC = ({ aigcSettings, onUpdateAIGCSettings, onVideosGenerated }) => {
const [projects, setProjects] = React.useState([initialProject]);
const [activeProjectId, setActiveProjectId] = React.useState(projects[0].id);
const [activeSceneId, setActiveSceneId] = React.useState(projects[0].scenes[0]?.id || null);
const [selectedProjects, setSelectedProjects] = React.useState([]);
const [isGenerating, setIsGenerating] = React.useState(false);
const [generationState, setGenerationState] = React.useState({ progress: 0, current: 0, total: 0 });
const [editorTab, setEditorTab] = React.useState<'avatar' | 'voice' | 'script' | 'background'>('script');
const [backgroundTab, setBackgroundTab] = React.useState<'color' | 'media'>('color');
const [isPolishing, setIsPolishing] = React.useState(false);
const [editingProjectId, setEditingProjectId] = React.useState(null);
const [editingProjectName, setEditingProjectName] = React.useState('');
const activeProject = React.useMemo(() => projects.find(p => p.id === activeProjectId), [projects, activeProjectId]);
const activeScene = React.useMemo(() => activeProject?.scenes.find(s => s.id === activeSceneId), [activeProject, activeSceneId]);
React.useEffect(() => {
if (activeProject && (!activeSceneId || !activeProject.scenes.some(s => s.id === activeSceneId))) {
setActiveSceneId(activeProject.scenes[0]?.id || null);
}
}, [activeProjectId, activeProject, activeSceneId]);
const updateProject = (updatedProject: VideoProject) => {
setProjects(prev => prev.map(p => p.id === updatedProject.id ? updatedProject : p));
};
const handleStartEditingProjectName = (project: VideoProject) => {
setEditingProjectId(project.id);
setEditingProjectName(project.name);
};
const handleCancelEditingProjectName = () => {
setEditingProjectId(null);
setEditingProjectName('');
};
const handleSaveProjectName = () => {
if (!editingProjectId || !editingProjectName.trim()) {
handleCancelEditingProjectName();
return;
}
setProjects(prev => prev.map(p =>
p.id === editingProjectId ? { ...p, name: editingProjectName.trim() } : p
));
handleCancelEditingProjectName();
};
const addProject = () => {
const newSceneId = `scene-${Date.now()}`;
const newScriptVersionId = `v-new-${Date.now()}`;
const newProject: VideoProject = {
id: `proj-${Date.now()}`,
name: `Untitled Video ${projects.length + 1}`,
aspectRatio: '16:9',
scenes: [{
id: newSceneId,
script: {
versions: [{ id: newScriptVersionId, text: 'New video script.' }],
selectedVersionId: newScriptVersionId,
},
avatarId: 'av1',
voiceId: 'vo1',
background: { type: 'image', value: (mockBackgrounds.images[0] as { type: 'url', value: string }).value },
avatarPosition: { x: 50, y: 10 },
avatarScale: 1
}]
};
setProjects(prev => [...prev, newProject]);
setActiveProjectId(newProject.id);
};
const deleteProject = (id: string) => {
const newProjects = projects.filter(p => p.id !== id);
if (newProjects.length === 0) return;
setProjects(newProjects);
if (activeProjectId === id) setActiveProjectId(newProjects[0].id);
};
const updateScene = (updatedScene: VideoScene) => {
if (!activeProject) return;
const updatedScenes = activeProject.scenes.map(s => s.id === updatedScene.id ? updatedScene : s);
updateProject({ ...activeProject, scenes: updatedScenes });
};
const handleAddScene = () => {
if (!activeProject) return;
const newScriptVersionId = `v-new-${Date.now()}`;
const newScene: VideoScene = {
id: `scene-${Date.now()}`,
script: {
versions: [{ id: newScriptVersionId, text: 'New scene script.' }],
selectedVersionId: newScriptVersionId,
},
avatarId: 'av2',
voiceId: 'vo2',
background: {type: 'color', value: '#111827'},
avatarPosition: { x: 50, y: 10 },
avatarScale: 1
};
const updatedScenes = [...activeProject.scenes, newScene];
updateProject({ ...activeProject, scenes: updatedScenes });
setActiveSceneId(newScene.id);
};
const handleDeleteScene = (sceneId: string) => {
if (!activeProject || activeProject.scenes.length <= 1) {
alert("A project must have at least one scene.");
return;
}
const updatedScenes = activeProject.scenes.filter(s => s.id !== sceneId);
updateProject({ ...activeProject, scenes: updatedScenes });
if (activeSceneId === sceneId) {
setActiveSceneId(updatedScenes[0]?.id || null);
}
};
const handleGenerate = () => {
const projectsToGenerate = projects.filter(p => selectedProjects.includes(p.id));
if (projectsToGenerate.length === 0) return;
setIsGenerating(true);
setGenerationState({ progress: 0, current: 1, total: projectsToGenerate.length });
};
React.useEffect(() => {
if (isGenerating) {
const interval = setInterval(() => {
setGenerationState(prev => {
const newProgress = prev.progress + 2;
if (newProgress >= 100) {
if (prev.current >= prev.total) {
clearInterval(interval);
const projectsToGenerate = projects.filter(p => selectedProjects.includes(p.id));
const newVideos: AIGCVideo[] = projectsToGenerate.map(p => ({ id: `vid-gen-${p.id}`, title: p.name, thumbnailUrl: `https://picsum.photos/seed/gen${p.id}/200/120`, videoUrl: 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4' }));
onVideosGenerated(newVideos);
setIsGenerating(false);
setSelectedProjects([]);
return prev;
}
return { ...prev, progress: 0, current: prev.current + 1 };
}
return { ...prev, progress: newProgress };
});
}, 50);
return () => clearInterval(interval);
}
}, [isGenerating, projects, selectedProjects, onVideosGenerated]);
const handleUploadMedia = (type: 'image' | 'video') => {
const isVideo = type === 'video';
const newMedia: MediaSource = {
type: 'file', // Simulating a file upload
value: {
name: isVideo ? `user_video_${Date.now()}.mp4` : `user_image_${Date.now()}.jpg`,
size: 1234567,
previewUrl: isVideo
? 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4'
: `https://picsum.photos/seed/user-img-${Date.now()}/800/450`
}
};
onUpdateAIGCSettings({
...aigcSettings,
userMedia: [...(aigcSettings.userMedia || []), newMedia]
});
};
const handleSelectMedia = (source: MediaSource) => {
if (!activeScene) return;
// FIX: Add explicit checks to ensure type-safe access to properties of the MediaSource union type.
let url: string | undefined;
let isVideo = false;
if (source.type === 'file') {
url = source.value.previewUrl;
isVideo = ['.mp4', '.webm', '.ogg'].some(ext => source.value.name.endsWith(ext));
} else if (source.type === 'url') {
url = source.value;
isVideo = ['.mp4', '.webm', '.ogg'].some(ext => url.endsWith(ext));
}
if (url) {
updateScene({ ...activeScene, background: { type: isVideo ? 'video' : 'image', value: url } });
}
};
if (!activeProject || !activeScene) return Loading editor...
const mediaGallery = [...mockBackgrounds.images, ...(aigcSettings.userMedia || [])];
const renderEditorContent = () => {
switch (editorTab) {
case 'avatar': return Avatar
{mockAvatars.map(avatar =>
updateScene({ ...activeScene, avatarId: avatar.id })} className={`rounded-lg overflow-hidden cursor-pointer border-2 ${activeScene.avatarId === avatar.id ? 'border-brand-primary ring-2 ring-brand-primary/50' : 'border-gray-200 dark:border-gray-700'}`}>

{avatar.name}
)}
case 'voice': return
Voice
case 'script': {
const scriptContent = activeScene.script;
const handleScriptTextChange = (versionId: string, newText: string) => {
const newVersions = scriptContent.versions.map(v =>
v.id === versionId ? { ...v, text: newText } : v
);
updateScene({
...activeScene,
script: { ...scriptContent, versions: newVersions },
});
};
const handleSelectVersion = (versionId: string) => {
updateScene({
...activeScene,
script: { ...scriptContent, selectedVersionId: versionId },
});
};
const handleAIPolish = async () => {
if (!activeScene || isPolishing) return;
const currentVersion = activeScene.script.versions.find(v => v.id === activeScene.script.selectedVersionId);
if (!currentVersion || !currentVersion.text.trim()) {
alert("Please write a script first.");
return;
}
setIsPolishing(true);
try {
const polishedVariations = await polishScript(currentVersion.text);
const newVersions = polishedVariations.map((text, i) => ({
id: `v-polished-${Date.now()}-${i}`,
text,
}));
updateScene({
...activeScene,
script: {
...activeScene.script,
versions: [...activeScene.script.versions, ...newVersions],
},
});
} catch (error) {
console.error("Failed to polish script:", error);
alert("Sorry, we couldn't polish the script at this time.");
} finally {
setIsPolishing(false);
}
};
return (
Script
{scriptContent.versions.map((version, index) => (
))}
);
}
case 'background': return (
Background
{backgroundTab === 'color' && (
{mockBackgrounds.colors.map(color => (
)}
{backgroundTab === 'media' && (
{mediaGallery.map((media, index) => {
let url: string | undefined;
let isVideo = false;
// FIX: Use explicit checks to ensure type-safe access to properties of the MediaSource union type.
if (media.type === 'file') {
url = media.value.previewUrl;
isVideo = ['.mp4', '.webm', '.ogg'].some(ext => media.value.name.endsWith(ext));
} else if (media.type === 'url') {
url = media.value;
isVideo = ['.mp4', '.webm', '.ogg'].some(ext => url.endsWith(ext));
}
if (!url) return null;
const thumbnailUrl = isVideo ? `https://picsum.photos/seed/vid-thumb-${index}/200` : url;
return (
)
})}
)}
);
}
}
return (
<>
{isGenerating && }
{activeProject.scenes.map((scene, index) => (
setActiveSceneId(scene.id)} className={`group relative p-2 rounded-lg cursor-pointer border-2 flex flex-col gap-2 items-center justify-center h-24 aspect-video flex-shrink-0 ${activeSceneId === scene.id ? 'border-brand-primary bg-gray-100 dark:bg-gray-700/50' : 'border-transparent bg-gray-50 dark:bg-gray-900/50 hover:bg-gray-100 dark:hover:bg-gray-700'}`}>
{`Scene ${index + 1}`}
))}
updateScene({ ...activeScene, avatarPosition: pos })} onAvatarScale={scale => updateScene({ ...activeScene, avatarScale: scale })}/>
>
);
};
export default VideoCreator;