| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636 |
- 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 }) => (
- <div className="fixed inset-0 bg-black/80 flex flex-col items-center justify-center z-50">
- <div className="bg-gray-800 p-8 rounded-lg text-center w-full max-w-md">
- <h3 className="text-xl font-bold text-white mb-4">Generating Videos...</h3>
- <p className="text-gray-400 mb-4">{`Processing video ${currentVideo} of ${totalVideos}. This may take a moment.`}</p>
- <div className="w-full bg-gray-700 rounded-full h-4">
- <div className="bg-brand-primary h-4 rounded-full transition-all duration-500" style={{ width: `${progress}%` }} />
- </div>
- </div>
- </div>
- );
- 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<HTMLDivElement>(null);
- const avatarRef = React.useRef<HTMLImageElement>(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<HTMLImageElement>) => {
- 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<HTMLDivElement>) => {
- 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<HTMLDivElement>) => {
- 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 (
- <div ref={canvasRef} onMouseDown={handleCanvasMouseDown}
- className={`bg-black rounded-lg relative overflow-hidden shadow-2xl transition-all duration-300 max-w-full max-h-full ${aspectRatio === '16:9' ? 'aspect-video w-full' : 'aspect-[9/16] h-full'}`}
- style={backgroundStyle}>
- {isVideoBg && (
- <video key={scene.background.value} src={scene.background.value} autoPlay loop muted className="absolute top-0 left-0 w-full h-full object-cover" />
- )}
- {activeAvatar &&
- <div
- className="absolute"
- style={{
- left: `${scene.avatarPosition.x}%`,
- bottom: `${scene.avatarPosition.y}%`,
- height: '80%',
- transform: `translateX(-50%)`,
- cursor: interactionState === 'dragging' ? 'grabbing' : 'grab',
- }}
- >
- <div className="relative w-auto h-full" style={{ transform: `scale(${scene.avatarScale})`, transformOrigin: 'bottom center' }}>
- <img
- ref={avatarRef}
- src={activeAvatar.imageUrl}
- alt={activeAvatar.name}
- onMouseDown={handleAvatarMouseDown}
- className="h-full object-contain select-none pointer-events-auto"
- />
- {isSelected && (
- <>
- <div className="absolute inset-0 border-2 border-dashed border-brand-primary pointer-events-none" />
- <div
- onMouseDown={handleResizeMouseDown}
- className="absolute -bottom-1 -right-1 w-4 h-4 bg-brand-primary border-2 border-white dark:border-gray-800 rounded-full cursor-se-resize pointer-events-auto"
- />
- </>
- )}
- </div>
- </div>
- }
- </div>
- );
- };
- // --- Main Component ---
- interface VideoCreatorProps {
- aigcSettings: AIGCSettings;
- onUpdateAIGCSettings: (newSettings: AIGCSettings) => void;
- onVideosGenerated: (videos: AIGCVideo[]) => void;
- }
- const VideoCreator: React.FC<VideoCreatorProps> = ({ aigcSettings, onUpdateAIGCSettings, onVideosGenerated }) => {
- const [projects, setProjects] = React.useState<VideoProject[]>([initialProject]);
- const [activeProjectId, setActiveProjectId] = React.useState<string>(projects[0].id);
- const [activeSceneId, setActiveSceneId] = React.useState<string | null>(projects[0].scenes[0]?.id || null);
- const [selectedProjects, setSelectedProjects] = React.useState<string[]>([]);
- 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<string | null>(null);
- const [editingProjectName, setEditingProjectName] = React.useState<string>('');
- 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 <div className="p-8 text-center text-gray-500 dark:text-gray-400">Loading editor...</div>
- const mediaGallery = [...mockBackgrounds.images, ...(aigcSettings.userMedia || [])];
- const renderEditorContent = () => {
- switch (editorTab) {
- case 'avatar': return <div><h4 className="font-semibold text-gray-900 dark:text-white mb-3">Avatar</h4><div className="grid grid-cols-3 gap-2">{mockAvatars.map(avatar => <div key={avatar.id} onClick={() => 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'}`}><img src={avatar.imageUrl} alt={avatar.name} className="w-full h-auto bg-gray-100 dark:bg-gray-700" /><p className="text-xs text-center bg-gray-200/50 dark:bg-gray-900/50 py-1 text-gray-900 dark:text-white">{avatar.name}</p></div>)}</div></div>
- case 'voice': return <div> <h4 className="font-semibold text-gray-900 dark:text-white mb-3">Voice</h4> <select value={activeScene.voiceId} onChange={e => updateScene({ ...activeScene, voiceId: e.target.value })} className="w-full bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600"> {mockVoices.map(voice => <option key={voice.id} value={voice.id}>{`${voice.name} (${voice.accent})`}</option>)}</select></div>
- 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 (
- <div>
- <div className="flex justify-between items-center mb-3">
- <h4 className="font-semibold text-gray-900 dark:text-white">Script</h4>
- <button
- onClick={handleAIPolish}
- disabled={isPolishing}
- className="flex items-center gap-2 text-sm font-semibold bg-gray-200 dark:bg-gray-700 px-3 py-1.5 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
- >
- <Icon className="h-4 w-4 text-brand-primary"><path strokeLinecap="round" strokeLinejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c.251.023.501.05.75.082a9.75 9.75 0 016 6.062c.313.958.5 1.965.5 3.004v.75a2.25 2.25 0 01-2.25 2.25H3.75a2.25 2.25 0 01-2.25-2.25v-.75c0-1.04.187-2.046.5-3.004a9.75 9.75 0 016-6.062 12.312 12.312 0 01.75-.082zM9.75 18.75c-2.482 0-4.72-1.22-6.16-3.223" /></Icon>
- {isPolishing ? 'Polishing...' : 'AI Polish'}
- </button>
- </div>
- <div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 -mr-4">
- {scriptContent.versions.map((version, index) => (
- <div key={version.id} className="flex items-start gap-3">
- <input
- type="radio"
- name={`script-version-${activeScene.id}`}
- id={`version-${version.id}`}
- checked={scriptContent.selectedVersionId === version.id}
- onChange={() => handleSelectVersion(version.id)}
- className="mt-3 h-4 w-4 bg-gray-100 dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-brand-primary focus:ring-brand-secondary"
- />
- <div className="flex-1">
- <label htmlFor={`version-text-${version.id}`} className="sr-only">{`Script Version ${index + 1}`}</label>
- <textarea
- id={`version-text-${version.id}`}
- rows={4}
- value={version.text}
- onChange={e => handleScriptTextChange(version.id, e.target.value)}
- className="w-full bg-gray-100 dark:bg-gray-700 p-2 rounded-md border border-gray-300 dark:border-gray-600 focus:ring-1 focus:ring-brand-primary focus:border-brand-primary"
- />
- </div>
- </div>
- ))}
- </div>
- </div>
- );
- }
- case 'background': return (
- <div className="flex flex-col h-full">
- <div className="flex-shrink-0">
- <h4 className="font-semibold text-gray-900 dark:text-white mb-3">Background</h4>
- <div className="flex gap-1 mb-3 p-1 bg-gray-200 dark:bg-gray-900 rounded-lg">
- <button onClick={() => { setBackgroundTab('color'); updateScene({ ...activeScene, background: { type: 'color', value: mockBackgrounds.colors[0] } })}} className={`flex-1 py-1 text-xs rounded-md ${backgroundTab === 'color' ? 'bg-brand-primary text-white shadow' : 'hover:bg-gray-300 dark:hover:bg-gray-700'}`}>Color</button>
- <button onClick={() => { setBackgroundTab('media'); if(mediaGallery.length > 0) handleSelectMedia(mediaGallery[0]); }} className={`flex-1 py-1 text-xs rounded-md ${backgroundTab === 'media' ? 'bg-brand-primary text-white shadow' : 'hover:bg-gray-300 dark:hover:bg-gray-700'}`}>Media</button>
- </div>
- </div>
- {backgroundTab === 'color' && (
- <div className="grid grid-cols-4 gap-2 flex-shrink-0">
- {mockBackgrounds.colors.map(color => (
- <button key={color} onClick={() => updateScene({ ...activeScene, background: { type: 'color', value: color } })} className={`h-12 rounded-md ${activeScene.background.value === color && activeScene.background.type === 'color' ? 'ring-2 ring-brand-primary ring-offset-2 ring-offset-gray-800' : ''}`} style={{ backgroundColor: color }} />
- ))}
- </div>
- )}
- {backgroundTab === 'media' && (
- <div className="flex flex-col flex-1 min-h-0">
- <div className="grid grid-cols-2 gap-2 mb-3 flex-shrink-0">
- <button onClick={() => handleUploadMedia('image')} className="w-full text-xs p-2 bg-gray-200 dark:bg-gray-700 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600">Upload Image</button>
- <button onClick={() => handleUploadMedia('video')} className="w-full text-xs p-2 bg-gray-200 dark:bg-gray-700 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600">Upload Video</button>
- </div>
- <div className="flex-1 overflow-y-auto pr-2 -mr-4">
- <div className="grid grid-cols-2 gap-3">
- {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 (
- <button key={`${url}-${index}`} onClick={() => handleSelectMedia(media)} className={`relative aspect-[16/9] rounded-lg bg-cover bg-center group ${activeScene.background.value === url ? 'ring-2 ring-brand-primary ring-offset-2 ring-offset-gray-800' : ''}`}>
- <img src={thumbnailUrl} className="w-full h-full object-cover rounded-md" alt="Media thumbnail"/>
- {isVideo && <div className="absolute inset-0 bg-black/50 flex items-center justify-center"><Icon className="h-6 w-6 text-white"><path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></Icon></div>}
- </button>
- )
- })}
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
- }
- return (
- <>
- {isGenerating && <GenerationProgress progress={generationState.progress} totalVideos={generationState.total} currentVideo={generationState.current} />}
- <div className="flex flex-col h-full bg-gray-100 dark:bg-gray-900">
- <div className="flex items-center gap-2 p-2 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-x-auto flex-shrink-0">
- {projects.map(p => (
- <div key={p.id} className={`flex items-center gap-2 p-2 rounded-md text-sm cursor-pointer border ${activeProjectId === p.id ? 'bg-brand-primary/10 border-brand-primary text-brand-primary' : 'border-transparent hover:bg-gray-200/50 dark:hover:bg-gray-700'}`}>
- <input type="checkbox" checked={selectedProjects.includes(p.id)} onChange={() => {setSelectedProjects(prev => prev.includes(p.id) ? prev.filter(id => id !== p.id) : [...prev, p.id]);}} className="h-4 w-4 rounded bg-gray-100 dark:bg-gray-900 border-gray-300 dark:border-gray-600 text-brand-primary focus:ring-brand-secondary"/>
- {editingProjectId === p.id ? (
- <input
- type="text"
- value={editingProjectName}
- onChange={e => setEditingProjectName(e.target.value)}
- onBlur={handleSaveProjectName}
- onKeyDown={e => {
- if (e.key === 'Enter') handleSaveProjectName();
- if (e.key === 'Escape') handleCancelEditingProjectName();
- }}
- autoFocus
- className="bg-white dark:bg-gray-700 text-sm p-1 rounded-sm border border-brand-primary focus:outline-none"
- />
- ) : (
- <span onDoubleClick={() => handleStartEditingProjectName(p)} onClick={() => setActiveProjectId(p.id)} title="Double-click to rename">
- {p.name}
- </span>
- )}
- <button onClick={() => deleteProject(p.id)} className="text-gray-400 dark:text-gray-500 hover:text-red-500">×</button>
- </div>
- ))}
- <button onClick={addProject} className="p-2 rounded-full h-8 w-8 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 text-lg flex-shrink-0">+</button>
- </div>
- <div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 flex items-center gap-3 overflow-x-auto flex-shrink-0">
- {activeProject.scenes.map((scene, index) => (
- <div key={scene.id} onClick={() => 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'}`}>
- <div className="w-full h-full bg-cover bg-center rounded" style={{background: scene.background.type === 'color' ? scene.background.value : `url(${scene.background.value}) center/cover`}}/>
- <p className="text-xs font-semibold text-gray-700 dark:text-gray-300">{`Scene ${index + 1}`}</p>
- <button onClick={e => { e.stopPropagation(); handleDeleteScene(scene.id);}} className="absolute -top-1 -right-1 h-5 w-5 bg-red-500 text-white rounded-full flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity">×</button>
- </div>
- ))}
- <button onClick={handleAddScene} className="h-24 aspect-video flex-shrink-0 flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg text-gray-500 dark:text-gray-400">
- <Icon className="h-8 w-8"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></Icon>
- <span className="text-xs mt-1">Add Scene</span>
- </button>
- </div>
-
- <div className="flex flex-1 overflow-hidden">
- <aside className="w-96 bg-white dark:bg-gray-800 p-4 flex flex-col border-r border-gray-200 dark:border-gray-700">
- <Tabs tabs={['avatar', 'voice', 'script', 'background']} activeTab={editorTab} onTabClick={(t) => setEditorTab(t as any)} />
- <div className="flex-1 overflow-y-auto pt-6 pr-2 -mr-4">{renderEditorContent()}</div>
- <div className="mt-4 flex-shrink-0"><button onClick={handleGenerate} disabled={selectedProjects.length === 0} className="w-full bg-brand-primary text-white font-bold py-3 px-4 rounded-md hover:bg-brand-secondary transition-colors disabled:bg-gray-500 disabled:cursor-not-allowed">{`Generate ${selectedProjects.length} Video${selectedProjects.length !== 1 ? 's' : ''}`}</button></div>
- </aside>
-
- <main className="flex-1 flex flex-col items-center justify-center p-8 bg-gray-100 dark:bg-gray-900 overflow-hidden space-y-4">
- <div className="flex items-center gap-1 bg-gray-200 dark:bg-gray-800 p-1 rounded-lg">
- <button onClick={() => updateProject({...activeProject, aspectRatio: '16:9'})} className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${activeProject.aspectRatio === '16:9' ? 'bg-brand-primary text-white' : 'hover:bg-gray-300 dark:hover:bg-gray-700'}`}>16:9</button>
- <button onClick={() => updateProject({...activeProject, aspectRatio: '9:16'})} className={`px-3 py-1 text-sm rounded-md flex items-center gap-2 ${activeProject.aspectRatio === '9:16' ? 'bg-brand-primary text-white' : 'hover:bg-gray-300 dark:hover:bg-gray-700'}`}>9:16</button>
- </div>
- <div className="w-full flex-1 flex items-center justify-center overflow-hidden">
- <VideoCanvas scene={activeScene} aspectRatio={activeProject.aspectRatio} onAvatarMove={pos => updateScene({ ...activeScene, avatarPosition: pos })} onAvatarScale={scale => updateScene({ ...activeScene, avatarScale: scale })}/>
- </div>
- </main>
- </div>
- </div>
- </>
- );
- };
- export default VideoCreator;
|