VideoCreator.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. import * as React from 'react';
  2. import { Avatar, Voice, VideoScene, VideoProject, AIGCVideo, AIGCSettings, MediaSource, ScriptContent, ScriptVersion } from '../types';
  3. import { Icon } from './ui/Icon';
  4. import { Tabs } from './ui/Tabs';
  5. import { polishScript } from '../services/geminiService';
  6. // --- MOCK DATA ---
  7. const mockAvatars: Avatar[] = [
  8. { id: 'av1', name: 'Robotix', imageUrl: 'https://api.dicebear.com/8.x/bottts-neutral/svg?seed=robotix' },
  9. { id: 'av2', name: 'Cyber', imageUrl: 'https://api.dicebear.com/8.x/bottts-neutral/svg?seed=cyber' },
  10. { id: 'av3', name: 'Gizmo', imageUrl: 'https://api.dicebear.com/8.x/bottts-neutral/svg?seed=gizmo' },
  11. ];
  12. const mockVoices: Voice[] = [ { id: 'vo1', name: 'Alloy', accent: 'Neutral' }, { id: 'vo2', name: 'Echo', accent: 'Warm' }, { id: 'vo3', name: 'Fable', accent: 'Professional' }];
  13. const mockBackgrounds = {
  14. colors: ['#111827', '#1f2937', '#374151', '#4b5563', '#6b7280', '#9ca3af', '#d1d5db', '#f3f4f6'],
  15. images: [
  16. { type: 'url', value: 'https://picsum.photos/seed/bg1/800/450' },
  17. { type: 'url', value: 'https://picsum.photos/seed/bg2/800/450' },
  18. { type: 'url', value: 'https://picsum.photos/seed/bg3/800/450' },
  19. { type: 'url', value: 'https://picsum.photos/seed/bg4/800/450' }
  20. ] as MediaSource[]
  21. };
  22. const initialScriptVersionId = `v-init-${Date.now()}`;
  23. const initialProject: VideoProject = {
  24. id: `proj-${Date.now()}`,
  25. name: 'My First Video',
  26. aspectRatio: '16:9',
  27. scenes: [{
  28. id: `scene-${Date.now()}`,
  29. script: {
  30. versions: [{ id: initialScriptVersionId, text: 'Welcome to our presentation!' }],
  31. selectedVersionId: initialScriptVersionId
  32. },
  33. avatarId: 'av1',
  34. voiceId: 'vo1',
  35. background: { type: 'color', value: '#1f2937' },
  36. avatarPosition: { x: 50, y: 10 },
  37. avatarScale: 1,
  38. }]
  39. };
  40. // --- Child Components ---
  41. const GenerationProgress: React.FC<{ progress: number; totalVideos: number; currentVideo: number }> = ({ progress, totalVideos, currentVideo }) => (
  42. <div className="fixed inset-0 bg-black/80 flex flex-col items-center justify-center z-50">
  43. <div className="bg-gray-800 p-8 rounded-lg text-center w-full max-w-md">
  44. <h3 className="text-xl font-bold text-white mb-4">Generating Videos...</h3>
  45. <p className="text-gray-400 mb-4">{`Processing video ${currentVideo} of ${totalVideos}. This may take a moment.`}</p>
  46. <div className="w-full bg-gray-700 rounded-full h-4">
  47. <div className="bg-brand-primary h-4 rounded-full transition-all duration-500" style={{ width: `${progress}%` }} />
  48. </div>
  49. </div>
  50. </div>
  51. );
  52. const VideoCanvas: React.FC<{
  53. scene: VideoScene;
  54. aspectRatio: '16:9' | '9:16';
  55. onAvatarMove: (pos: { x: number, y: number }) => void;
  56. onAvatarScale: (newScale: number) => void;
  57. }> = ({ scene, aspectRatio, onAvatarMove, onAvatarScale }) => {
  58. const canvasRef = React.useRef<HTMLDivElement>(null);
  59. const avatarRef = React.useRef<HTMLImageElement>(null);
  60. // Interaction state
  61. const [isSelected, setIsSelected] = React.useState(false);
  62. const [interactionState, setInteractionState] = React.useState<'idle' | 'dragging' | 'resizing'>('idle');
  63. // Refs to store start positions for calculations
  64. const interactionStartRef = React.useRef({
  65. dragOffsetX: 0,
  66. dragOffsetY: 0,
  67. resizeStartX: 0,
  68. resizeStartWidth: 0,
  69. resizeStartScale: 1,
  70. });
  71. const activeAvatar = mockAvatars.find(a => a.id === scene.avatarId);
  72. // Mouse Down on Avatar -> start dragging
  73. const handleAvatarMouseDown = (e: React.MouseEvent<HTMLImageElement>) => {
  74. e.preventDefault();
  75. e.stopPropagation();
  76. if (!avatarRef.current || !canvasRef.current) return;
  77. setIsSelected(true);
  78. setInteractionState('dragging');
  79. const avatarRect = avatarRef.current.getBoundingClientRect();
  80. interactionStartRef.current = {
  81. ...interactionStartRef.current,
  82. dragOffsetX: e.clientX - avatarRect.left,
  83. dragOffsetY: e.clientY - avatarRect.top,
  84. };
  85. };
  86. // Mouse Down on Resize Handle -> start resizing
  87. const handleResizeMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
  88. e.preventDefault();
  89. e.stopPropagation();
  90. if (!avatarRef.current) return;
  91. setInteractionState('resizing');
  92. interactionStartRef.current = {
  93. ...interactionStartRef.current,
  94. resizeStartX: e.clientX,
  95. resizeStartWidth: avatarRef.current.clientWidth,
  96. resizeStartScale: scene.avatarScale,
  97. };
  98. };
  99. // Effect for global mouse move and mouse up events
  100. React.useEffect(() => {
  101. const handleMouseMove = (e: MouseEvent) => {
  102. if (interactionState === 'idle' || !canvasRef.current) return;
  103. e.preventDefault();
  104. if (interactionState === 'dragging') {
  105. if (!avatarRef.current) return;
  106. const canvasRect = canvasRef.current.getBoundingClientRect();
  107. const newAvatarLeft = e.clientX - canvasRect.left - interactionStartRef.current.dragOffsetX;
  108. const newAvatarTop = e.clientY - canvasRect.top - interactionStartRef.current.dragOffsetY;
  109. const avatarWidth = avatarRef.current.offsetWidth;
  110. const avatarHeight = avatarRef.current.offsetHeight;
  111. const newXPercent = ((newAvatarLeft + avatarWidth / 2) / canvasRect.width) * 100;
  112. const newYFromBottom = canvasRect.height - newAvatarTop - avatarHeight;
  113. const newYPercent = (newYFromBottom / canvasRect.height) * 100;
  114. const clampedX = Math.max((avatarWidth / 2 / canvasRect.width) * 100, Math.min(100 - (avatarWidth / 2 / canvasRect.width) * 100, newXPercent));
  115. const clampedY = Math.max(0, Math.min(100 - (avatarHeight / canvasRect.height) * 100, newYPercent));
  116. onAvatarMove({ x: clampedX, y: clampedY });
  117. }
  118. if (interactionState === 'resizing') {
  119. const deltaX = e.clientX - interactionStartRef.current.resizeStartX;
  120. const newWidth = interactionStartRef.current.resizeStartWidth + deltaX;
  121. const baseWidth = interactionStartRef.current.resizeStartWidth / interactionStartRef.current.resizeStartScale;
  122. if (baseWidth === 0) return;
  123. const newScale = newWidth / baseWidth;
  124. onAvatarScale(Math.max(0.2, Math.min(3.0, newScale)));
  125. }
  126. };
  127. const handleMouseUp = () => {
  128. setInteractionState('idle');
  129. };
  130. window.addEventListener('mousemove', handleMouseMove);
  131. window.addEventListener('mouseup', handleMouseUp);
  132. return () => {
  133. window.removeEventListener('mousemove', handleMouseMove);
  134. window.removeEventListener('mouseup', handleMouseUp);
  135. };
  136. }, [interactionState, onAvatarMove, onAvatarScale]);
  137. // Deselect avatar when clicking on canvas background
  138. const handleCanvasMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
  139. if (e.target === e.currentTarget) {
  140. setIsSelected(false);
  141. }
  142. };
  143. const isVideoBg = scene.background.type === 'video';
  144. const backgroundStyle = scene.background.type !== 'video' ? {
  145. background: scene.background.type === 'color' ? scene.background.value : `url(${scene.background.value}) center/cover`
  146. } : {};
  147. return (
  148. <div ref={canvasRef} onMouseDown={handleCanvasMouseDown}
  149. 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'}`}
  150. style={backgroundStyle}>
  151. {isVideoBg && (
  152. <video key={scene.background.value} src={scene.background.value} autoPlay loop muted className="absolute top-0 left-0 w-full h-full object-cover" />
  153. )}
  154. {activeAvatar &&
  155. <div
  156. className="absolute"
  157. style={{
  158. left: `${scene.avatarPosition.x}%`,
  159. bottom: `${scene.avatarPosition.y}%`,
  160. height: '80%',
  161. transform: `translateX(-50%)`,
  162. cursor: interactionState === 'dragging' ? 'grabbing' : 'grab',
  163. }}
  164. >
  165. <div className="relative w-auto h-full" style={{ transform: `scale(${scene.avatarScale})`, transformOrigin: 'bottom center' }}>
  166. <img
  167. ref={avatarRef}
  168. src={activeAvatar.imageUrl}
  169. alt={activeAvatar.name}
  170. onMouseDown={handleAvatarMouseDown}
  171. className="h-full object-contain select-none pointer-events-auto"
  172. />
  173. {isSelected && (
  174. <>
  175. <div className="absolute inset-0 border-2 border-dashed border-brand-primary pointer-events-none" />
  176. <div
  177. onMouseDown={handleResizeMouseDown}
  178. 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"
  179. />
  180. </>
  181. )}
  182. </div>
  183. </div>
  184. }
  185. </div>
  186. );
  187. };
  188. // --- Main Component ---
  189. interface VideoCreatorProps {
  190. aigcSettings: AIGCSettings;
  191. onUpdateAIGCSettings: (newSettings: AIGCSettings) => void;
  192. onVideosGenerated: (videos: AIGCVideo[]) => void;
  193. }
  194. const VideoCreator: React.FC<VideoCreatorProps> = ({ aigcSettings, onUpdateAIGCSettings, onVideosGenerated }) => {
  195. const [projects, setProjects] = React.useState<VideoProject[]>([initialProject]);
  196. const [activeProjectId, setActiveProjectId] = React.useState<string>(projects[0].id);
  197. const [activeSceneId, setActiveSceneId] = React.useState<string | null>(projects[0].scenes[0]?.id || null);
  198. const [selectedProjects, setSelectedProjects] = React.useState<string[]>([]);
  199. const [isGenerating, setIsGenerating] = React.useState(false);
  200. const [generationState, setGenerationState] = React.useState({ progress: 0, current: 0, total: 0 });
  201. const [editorTab, setEditorTab] = React.useState<'avatar' | 'voice' | 'script' | 'background'>('script');
  202. const [backgroundTab, setBackgroundTab] = React.useState<'color' | 'media'>('color');
  203. const [isPolishing, setIsPolishing] = React.useState(false);
  204. const [editingProjectId, setEditingProjectId] = React.useState<string | null>(null);
  205. const [editingProjectName, setEditingProjectName] = React.useState<string>('');
  206. const activeProject = React.useMemo(() => projects.find(p => p.id === activeProjectId), [projects, activeProjectId]);
  207. const activeScene = React.useMemo(() => activeProject?.scenes.find(s => s.id === activeSceneId), [activeProject, activeSceneId]);
  208. React.useEffect(() => {
  209. if (activeProject && (!activeSceneId || !activeProject.scenes.some(s => s.id === activeSceneId))) {
  210. setActiveSceneId(activeProject.scenes[0]?.id || null);
  211. }
  212. }, [activeProjectId, activeProject, activeSceneId]);
  213. const updateProject = (updatedProject: VideoProject) => {
  214. setProjects(prev => prev.map(p => p.id === updatedProject.id ? updatedProject : p));
  215. };
  216. const handleStartEditingProjectName = (project: VideoProject) => {
  217. setEditingProjectId(project.id);
  218. setEditingProjectName(project.name);
  219. };
  220. const handleCancelEditingProjectName = () => {
  221. setEditingProjectId(null);
  222. setEditingProjectName('');
  223. };
  224. const handleSaveProjectName = () => {
  225. if (!editingProjectId || !editingProjectName.trim()) {
  226. handleCancelEditingProjectName();
  227. return;
  228. }
  229. setProjects(prev => prev.map(p =>
  230. p.id === editingProjectId ? { ...p, name: editingProjectName.trim() } : p
  231. ));
  232. handleCancelEditingProjectName();
  233. };
  234. const addProject = () => {
  235. const newSceneId = `scene-${Date.now()}`;
  236. const newScriptVersionId = `v-new-${Date.now()}`;
  237. const newProject: VideoProject = {
  238. id: `proj-${Date.now()}`,
  239. name: `Untitled Video ${projects.length + 1}`,
  240. aspectRatio: '16:9',
  241. scenes: [{
  242. id: newSceneId,
  243. script: {
  244. versions: [{ id: newScriptVersionId, text: 'New video script.' }],
  245. selectedVersionId: newScriptVersionId,
  246. },
  247. avatarId: 'av1',
  248. voiceId: 'vo1',
  249. background: { type: 'image', value: (mockBackgrounds.images[0] as { type: 'url', value: string }).value },
  250. avatarPosition: { x: 50, y: 10 },
  251. avatarScale: 1
  252. }]
  253. };
  254. setProjects(prev => [...prev, newProject]);
  255. setActiveProjectId(newProject.id);
  256. };
  257. const deleteProject = (id: string) => {
  258. const newProjects = projects.filter(p => p.id !== id);
  259. if (newProjects.length === 0) return;
  260. setProjects(newProjects);
  261. if (activeProjectId === id) setActiveProjectId(newProjects[0].id);
  262. };
  263. const updateScene = (updatedScene: VideoScene) => {
  264. if (!activeProject) return;
  265. const updatedScenes = activeProject.scenes.map(s => s.id === updatedScene.id ? updatedScene : s);
  266. updateProject({ ...activeProject, scenes: updatedScenes });
  267. };
  268. const handleAddScene = () => {
  269. if (!activeProject) return;
  270. const newScriptVersionId = `v-new-${Date.now()}`;
  271. const newScene: VideoScene = {
  272. id: `scene-${Date.now()}`,
  273. script: {
  274. versions: [{ id: newScriptVersionId, text: 'New scene script.' }],
  275. selectedVersionId: newScriptVersionId,
  276. },
  277. avatarId: 'av2',
  278. voiceId: 'vo2',
  279. background: {type: 'color', value: '#111827'},
  280. avatarPosition: { x: 50, y: 10 },
  281. avatarScale: 1
  282. };
  283. const updatedScenes = [...activeProject.scenes, newScene];
  284. updateProject({ ...activeProject, scenes: updatedScenes });
  285. setActiveSceneId(newScene.id);
  286. };
  287. const handleDeleteScene = (sceneId: string) => {
  288. if (!activeProject || activeProject.scenes.length <= 1) {
  289. alert("A project must have at least one scene.");
  290. return;
  291. }
  292. const updatedScenes = activeProject.scenes.filter(s => s.id !== sceneId);
  293. updateProject({ ...activeProject, scenes: updatedScenes });
  294. if (activeSceneId === sceneId) {
  295. setActiveSceneId(updatedScenes[0]?.id || null);
  296. }
  297. };
  298. const handleGenerate = () => {
  299. const projectsToGenerate = projects.filter(p => selectedProjects.includes(p.id));
  300. if (projectsToGenerate.length === 0) return;
  301. setIsGenerating(true);
  302. setGenerationState({ progress: 0, current: 1, total: projectsToGenerate.length });
  303. };
  304. React.useEffect(() => {
  305. if (isGenerating) {
  306. const interval = setInterval(() => {
  307. setGenerationState(prev => {
  308. const newProgress = prev.progress + 2;
  309. if (newProgress >= 100) {
  310. if (prev.current >= prev.total) {
  311. clearInterval(interval);
  312. const projectsToGenerate = projects.filter(p => selectedProjects.includes(p.id));
  313. 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' }));
  314. onVideosGenerated(newVideos);
  315. setIsGenerating(false);
  316. setSelectedProjects([]);
  317. return prev;
  318. }
  319. return { ...prev, progress: 0, current: prev.current + 1 };
  320. }
  321. return { ...prev, progress: newProgress };
  322. });
  323. }, 50);
  324. return () => clearInterval(interval);
  325. }
  326. }, [isGenerating, projects, selectedProjects, onVideosGenerated]);
  327. const handleUploadMedia = (type: 'image' | 'video') => {
  328. const isVideo = type === 'video';
  329. const newMedia: MediaSource = {
  330. type: 'file', // Simulating a file upload
  331. value: {
  332. name: isVideo ? `user_video_${Date.now()}.mp4` : `user_image_${Date.now()}.jpg`,
  333. size: 1234567,
  334. previewUrl: isVideo
  335. ? 'https://storage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4'
  336. : `https://picsum.photos/seed/user-img-${Date.now()}/800/450`
  337. }
  338. };
  339. onUpdateAIGCSettings({
  340. ...aigcSettings,
  341. userMedia: [...(aigcSettings.userMedia || []), newMedia]
  342. });
  343. };
  344. const handleSelectMedia = (source: MediaSource) => {
  345. if (!activeScene) return;
  346. // FIX: Add explicit checks to ensure type-safe access to properties of the MediaSource union type.
  347. let url: string | undefined;
  348. let isVideo = false;
  349. if (source.type === 'file') {
  350. url = source.value.previewUrl;
  351. isVideo = ['.mp4', '.webm', '.ogg'].some(ext => source.value.name.endsWith(ext));
  352. } else if (source.type === 'url') {
  353. url = source.value;
  354. isVideo = ['.mp4', '.webm', '.ogg'].some(ext => url.endsWith(ext));
  355. }
  356. if (url) {
  357. updateScene({ ...activeScene, background: { type: isVideo ? 'video' : 'image', value: url } });
  358. }
  359. };
  360. if (!activeProject || !activeScene) return <div className="p-8 text-center text-gray-500 dark:text-gray-400">Loading editor...</div>
  361. const mediaGallery = [...mockBackgrounds.images, ...(aigcSettings.userMedia || [])];
  362. const renderEditorContent = () => {
  363. switch (editorTab) {
  364. 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>
  365. 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>
  366. case 'script': {
  367. const scriptContent = activeScene.script;
  368. const handleScriptTextChange = (versionId: string, newText: string) => {
  369. const newVersions = scriptContent.versions.map(v =>
  370. v.id === versionId ? { ...v, text: newText } : v
  371. );
  372. updateScene({
  373. ...activeScene,
  374. script: { ...scriptContent, versions: newVersions },
  375. });
  376. };
  377. const handleSelectVersion = (versionId: string) => {
  378. updateScene({
  379. ...activeScene,
  380. script: { ...scriptContent, selectedVersionId: versionId },
  381. });
  382. };
  383. const handleAIPolish = async () => {
  384. if (!activeScene || isPolishing) return;
  385. const currentVersion = activeScene.script.versions.find(v => v.id === activeScene.script.selectedVersionId);
  386. if (!currentVersion || !currentVersion.text.trim()) {
  387. alert("Please write a script first.");
  388. return;
  389. }
  390. setIsPolishing(true);
  391. try {
  392. const polishedVariations = await polishScript(currentVersion.text);
  393. const newVersions = polishedVariations.map((text, i) => ({
  394. id: `v-polished-${Date.now()}-${i}`,
  395. text,
  396. }));
  397. updateScene({
  398. ...activeScene,
  399. script: {
  400. ...activeScene.script,
  401. versions: [...activeScene.script.versions, ...newVersions],
  402. },
  403. });
  404. } catch (error) {
  405. console.error("Failed to polish script:", error);
  406. alert("Sorry, we couldn't polish the script at this time.");
  407. } finally {
  408. setIsPolishing(false);
  409. }
  410. };
  411. return (
  412. <div>
  413. <div className="flex justify-between items-center mb-3">
  414. <h4 className="font-semibold text-gray-900 dark:text-white">Script</h4>
  415. <button
  416. onClick={handleAIPolish}
  417. disabled={isPolishing}
  418. 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"
  419. >
  420. <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>
  421. {isPolishing ? 'Polishing...' : 'AI Polish'}
  422. </button>
  423. </div>
  424. <div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2 -mr-4">
  425. {scriptContent.versions.map((version, index) => (
  426. <div key={version.id} className="flex items-start gap-3">
  427. <input
  428. type="radio"
  429. name={`script-version-${activeScene.id}`}
  430. id={`version-${version.id}`}
  431. checked={scriptContent.selectedVersionId === version.id}
  432. onChange={() => handleSelectVersion(version.id)}
  433. 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"
  434. />
  435. <div className="flex-1">
  436. <label htmlFor={`version-text-${version.id}`} className="sr-only">{`Script Version ${index + 1}`}</label>
  437. <textarea
  438. id={`version-text-${version.id}`}
  439. rows={4}
  440. value={version.text}
  441. onChange={e => handleScriptTextChange(version.id, e.target.value)}
  442. 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"
  443. />
  444. </div>
  445. </div>
  446. ))}
  447. </div>
  448. </div>
  449. );
  450. }
  451. case 'background': return (
  452. <div className="flex flex-col h-full">
  453. <div className="flex-shrink-0">
  454. <h4 className="font-semibold text-gray-900 dark:text-white mb-3">Background</h4>
  455. <div className="flex gap-1 mb-3 p-1 bg-gray-200 dark:bg-gray-900 rounded-lg">
  456. <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>
  457. <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>
  458. </div>
  459. </div>
  460. {backgroundTab === 'color' && (
  461. <div className="grid grid-cols-4 gap-2 flex-shrink-0">
  462. {mockBackgrounds.colors.map(color => (
  463. <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 }} />
  464. ))}
  465. </div>
  466. )}
  467. {backgroundTab === 'media' && (
  468. <div className="flex flex-col flex-1 min-h-0">
  469. <div className="grid grid-cols-2 gap-2 mb-3 flex-shrink-0">
  470. <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>
  471. <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>
  472. </div>
  473. <div className="flex-1 overflow-y-auto pr-2 -mr-4">
  474. <div className="grid grid-cols-2 gap-3">
  475. {mediaGallery.map((media, index) => {
  476. let url: string | undefined;
  477. let isVideo = false;
  478. // FIX: Use explicit checks to ensure type-safe access to properties of the MediaSource union type.
  479. if (media.type === 'file') {
  480. url = media.value.previewUrl;
  481. isVideo = ['.mp4', '.webm', '.ogg'].some(ext => media.value.name.endsWith(ext));
  482. } else if (media.type === 'url') {
  483. url = media.value;
  484. isVideo = ['.mp4', '.webm', '.ogg'].some(ext => url.endsWith(ext));
  485. }
  486. if (!url) return null;
  487. const thumbnailUrl = isVideo ? `https://picsum.photos/seed/vid-thumb-${index}/200` : url;
  488. return (
  489. <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' : ''}`}>
  490. <img src={thumbnailUrl} className="w-full h-full object-cover rounded-md" alt="Media thumbnail"/>
  491. {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>}
  492. </button>
  493. )
  494. })}
  495. </div>
  496. </div>
  497. </div>
  498. )}
  499. </div>
  500. );
  501. }
  502. }
  503. return (
  504. <>
  505. {isGenerating && <GenerationProgress progress={generationState.progress} totalVideos={generationState.total} currentVideo={generationState.current} />}
  506. <div className="flex flex-col h-full bg-gray-100 dark:bg-gray-900">
  507. <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">
  508. {projects.map(p => (
  509. <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'}`}>
  510. <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"/>
  511. {editingProjectId === p.id ? (
  512. <input
  513. type="text"
  514. value={editingProjectName}
  515. onChange={e => setEditingProjectName(e.target.value)}
  516. onBlur={handleSaveProjectName}
  517. onKeyDown={e => {
  518. if (e.key === 'Enter') handleSaveProjectName();
  519. if (e.key === 'Escape') handleCancelEditingProjectName();
  520. }}
  521. autoFocus
  522. className="bg-white dark:bg-gray-700 text-sm p-1 rounded-sm border border-brand-primary focus:outline-none"
  523. />
  524. ) : (
  525. <span onDoubleClick={() => handleStartEditingProjectName(p)} onClick={() => setActiveProjectId(p.id)} title="Double-click to rename">
  526. {p.name}
  527. </span>
  528. )}
  529. <button onClick={() => deleteProject(p.id)} className="text-gray-400 dark:text-gray-500 hover:text-red-500">×</button>
  530. </div>
  531. ))}
  532. <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>
  533. </div>
  534. <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">
  535. {activeProject.scenes.map((scene, index) => (
  536. <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'}`}>
  537. <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`}}/>
  538. <p className="text-xs font-semibold text-gray-700 dark:text-gray-300">{`Scene ${index + 1}`}</p>
  539. <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>
  540. </div>
  541. ))}
  542. <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">
  543. <Icon className="h-8 w-8"><path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></Icon>
  544. <span className="text-xs mt-1">Add Scene</span>
  545. </button>
  546. </div>
  547. <div className="flex flex-1 overflow-hidden">
  548. <aside className="w-96 bg-white dark:bg-gray-800 p-4 flex flex-col border-r border-gray-200 dark:border-gray-700">
  549. <Tabs tabs={['avatar', 'voice', 'script', 'background']} activeTab={editorTab} onTabClick={(t) => setEditorTab(t as any)} />
  550. <div className="flex-1 overflow-y-auto pt-6 pr-2 -mr-4">{renderEditorContent()}</div>
  551. <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>
  552. </aside>
  553. <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">
  554. <div className="flex items-center gap-1 bg-gray-200 dark:bg-gray-800 p-1 rounded-lg">
  555. <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>
  556. <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>
  557. </div>
  558. <div className="w-full flex-1 flex items-center justify-center overflow-hidden">
  559. <VideoCanvas scene={activeScene} aspectRatio={activeProject.aspectRatio} onAvatarMove={pos => updateScene({ ...activeScene, avatarPosition: pos })} onAvatarScale={scale => updateScene({ ...activeScene, avatarScale: scale })}/>
  560. </div>
  561. </main>
  562. </div>
  563. </div>
  564. </>
  565. );
  566. };
  567. export default VideoCreator;