ly0303521

前端页面添加视频生成功能

@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
2 import { ImageItem, ImageGenerationParams, UserProfile } from './types'; 2 import { ImageItem, ImageGenerationParams, UserProfile } from './types';
3 import { SHOWCASE_IMAGES, ADMIN_ID } from './constants'; 3 import { SHOWCASE_IMAGES, ADMIN_ID } from './constants';
4 import { generateImage } from './services/imageService'; 4 import { generateImage } from './services/imageService';
  5 +import { generateVideo } from './services/videoService';
5 import { fetchGallery, toggleLike } from './services/galleryService'; 6 import { fetchGallery, toggleLike } from './services/galleryService';
6 import MasonryGrid from './components/MasonryGrid'; 7 import MasonryGrid from './components/MasonryGrid';
7 import InputBar from './components/InputBar'; 8 import InputBar from './components/InputBar';
@@ -10,18 +11,28 @@ import DetailModal from './components/DetailModal'; @@ -10,18 +11,28 @@ import DetailModal from './components/DetailModal';
10 import AdminModal from './components/AdminModal'; 11 import AdminModal from './components/AdminModal';
11 import AuthModal from './components/AuthModal'; 12 import AuthModal from './components/AuthModal';
12 import WhitelistModal from './components/WhitelistModal'; 13 import WhitelistModal from './components/WhitelistModal';
13 -import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users } from 'lucide-react'; 14 +import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video } from 'lucide-react';
14 15
15 const STORAGE_KEY_DATA = 'z-image-gallery-data-v2'; 16 const STORAGE_KEY_DATA = 'z-image-gallery-data-v2';
  17 +const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1';
16 const STORAGE_KEY_USER = 'z-image-user-profile'; 18 const STORAGE_KEY_USER = 'z-image-user-profile';
17 const MIN_GALLERY_ITEMS = 8; 19 const MIN_GALLERY_ITEMS = 8;
18 20
  21 +// --- Enums and Types ---
  22 +enum GalleryMode {
  23 + Image,
  24 + Video,
  25 +}
  26 +
19 const App: React.FC = () => { 27 const App: React.FC = () => {
20 // --- State: User --- 28 // --- State: User ---
21 const [currentUser, setCurrentUser] = useState<UserProfile | null>(null); 29 const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
22 const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); 30 const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
23 const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false); 31 const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false);
24 32
  33 + // --- State: UI ---
  34 + const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image);
  35 +
25 // --- State: Data --- 36 // --- State: Data ---
26 const [images, setImages] = useState<ImageItem[]>(() => { 37 const [images, setImages] = useState<ImageItem[]>(() => {
27 try { 38 try {
@@ -30,8 +41,17 @@ const App: React.FC = () => { @@ -30,8 +41,17 @@ const App: React.FC = () => {
30 } catch (e) { console.error(e); } 41 } catch (e) { console.error(e); }
31 return SHOWCASE_IMAGES; 42 return SHOWCASE_IMAGES;
32 }); 43 });
  44 + const [videos, setVideos] = useState<ImageItem[]>(() => {
  45 + try {
  46 + const saved = localStorage.getItem(STORAGE_KEY_VIDEO_DATA);
  47 + if (saved) return JSON.parse(saved);
  48 + } catch(e) { console.error(e); }
  49 + return [];
  50 + });
  51 +
33 52
34 const [isGenerating, setIsGenerating] = useState(false); 53 const [isGenerating, setIsGenerating] = useState(false);
  54 + const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
35 const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); 55 const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
36 const [error, setError] = useState<string | null>(null); 56 const [error, setError] = useState<string | null>(null);
37 const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); 57 const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
@@ -66,6 +86,11 @@ const App: React.FC = () => { @@ -66,6 +86,11 @@ const App: React.FC = () => {
66 catch (e) { console.error("Storage full", e); } 86 catch (e) { console.error("Storage full", e); }
67 }, [images]); 87 }, [images]);
68 88
  89 + useEffect(() => {
  90 + try { localStorage.setItem(STORAGE_KEY_VIDEO_DATA, JSON.stringify(videos)); }
  91 + catch (e) { console.error("Storage full for videos", e); }
  92 + }, [videos]);
  93 +
69 const handleLogin = (employeeId: string) => { 94 const handleLogin = (employeeId: string) => {
70 const user: UserProfile = { employeeId, hasAccess: true }; 95 const user: UserProfile = { employeeId, hasAccess: true };
71 setCurrentUser(user); 96 setCurrentUser(user);
@@ -82,6 +107,10 @@ const App: React.FC = () => { @@ -82,6 +107,10 @@ const App: React.FC = () => {
82 }; 107 };
83 108
84 const syncGallery = useCallback(async () => { 109 const syncGallery = useCallback(async () => {
  110 + if (galleryMode === GalleryMode.Video) {
  111 + // We are not syncing videos from a central gallery in this version.
  112 + return;
  113 + }
85 try { 114 try {
86 const remoteImages = await fetchGallery(); 115 const remoteImages = await fetchGallery();
87 setImages(prev => { 116 setImages(prev => {
@@ -113,7 +142,7 @@ const App: React.FC = () => { @@ -113,7 +142,7 @@ const App: React.FC = () => {
113 console.error("Failed to sync gallery", err); 142 console.error("Failed to sync gallery", err);
114 // Keep previous state if sync fails 143 // Keep previous state if sync fails
115 } 144 }
116 - }, [currentUser]); 145 + }, [currentUser, galleryMode]);
117 146
118 useEffect(() => { 147 useEffect(() => {
119 syncGallery(); 148 syncGallery();
@@ -121,7 +150,43 @@ const App: React.FC = () => { @@ -121,7 +150,43 @@ const App: React.FC = () => {
121 return () => clearInterval(interval); 150 return () => clearInterval(interval);
122 }, [syncGallery]); 151 }, [syncGallery]);
123 152
124 - const handleGenerate = async (uiParams: ImageGenerationParams) => { 153 + const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => {
  154 + if (!currentUser) {
  155 + setIsAuthModalOpen(true);
  156 + return;
  157 + }
  158 + setIsGeneratingVideo(true);
  159 + setError(null);
  160 + try {
  161 + const videoUrl = await generateVideo(params.prompt, imageFile);
  162 + const newVideo: ImageItem = {
  163 + id: `vid-${Date.now()}`,
  164 + url: videoUrl,
  165 + prompt: params.prompt,
  166 + authorId: currentUser.employeeId,
  167 + createdAt: Date.now(),
  168 + likes: 0,
  169 + isLikedByCurrentUser: false,
  170 + };
  171 + setVideos(prev => [newVideo, ...prev]);
  172 + } catch (err: any) {
  173 + console.error(err);
  174 + setError("视频生成失败。请确保视频生成服务正常运行。");
  175 + } finally {
  176 + setIsGeneratingVideo(false);
  177 + }
  178 + };
  179 +
  180 + const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => {
  181 + if (galleryMode === GalleryMode.Video) {
  182 + if (!imageFile) {
  183 + alert("请上传一张图片以生成视频。");
  184 + return;
  185 + }
  186 + handleGenerateVideo(uiParams, imageFile);
  187 + return;
  188 + }
  189 +
125 if (!currentUser) { 190 if (!currentUser) {
126 setIsAuthModalOpen(true); 191 setIsAuthModalOpen(true);
127 return; 192 return;
@@ -278,25 +343,48 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 @@ -278,25 +343,48 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
278 </div> 343 </div>
279 </header> 344 </header>
280 345
  346 + {/* --- Gallery Mode Tabs --- */}
  347 + <div className="px-6 md:px-12 mt-6 mb-4 flex border-b">
  348 + <button
  349 + onClick={() => setGalleryMode(GalleryMode.Image)}
  350 + className={`px-4 py-2 font-medium text-sm transition-colors ${galleryMode === GalleryMode.Image ? 'border-b-2 border-purple-600 text-purple-600' : 'text-gray-500 hover:text-gray-800'}`}
  351 + >
  352 + 灵感图库
  353 + </button>
  354 + <button
  355 + onClick={() => setGalleryMode(GalleryMode.Video)}
  356 + className={`px-4 py-2 font-medium text-sm transition-colors ${galleryMode === GalleryMode.Video ? 'border-b-2 border-purple-600 text-purple-600' : 'text-gray-500 hover:text-gray-800'}`}
  357 + >
  358 + 视频素材
  359 + </button>
  360 + </div>
  361 +
281 {error && <div className="mx-6 md:mx-12 mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm flex justify-between"><span>{error}</span><button onClick={() => setError(null)}><Trash2 size={14}/></button></div>} 362 {error && <div className="mx-6 md:mx-12 mt-4 p-4 bg-red-50 border border-red-200 text-red-600 rounded-lg text-sm flex justify-between"><span>{error}</span><button onClick={() => setError(null)}><Trash2 size={14}/></button></div>}
282 363
283 <main> 364 <main>
284 - {isGenerating && ( 365 + {(isGenerating || isGeneratingVideo) && (
285 <div className="w-full flex justify-center py-12"> 366 <div className="w-full flex justify-center py-12">
286 <div className="flex flex-col items-center animate-pulse"> 367 <div className="flex flex-col items-center animate-pulse">
287 <Loader2 className="animate-spin text-purple-600 mb-3" size={40} /> 368 <Loader2 className="animate-spin text-purple-600 mb-3" size={40} />
288 - <span className="text-gray-500 font-medium">绘图引擎全力启动中...</span> 369 + <span className="text-gray-500 font-medium">
  370 + {isGeneratingVideo ? "视频生成中,请耐心等待..." : "绘图引擎全力启动中..."}
  371 + </span>
289 </div> 372 </div>
290 </div> 373 </div>
291 )} 374 )}
292 375
  376 + {galleryMode === GalleryMode.Image ? (
293 <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/> 377 <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/>
  378 + ) : (
  379 + <MasonryGrid images={videos} onImageClick={setSelectedImage} onLike={() => {}} currentUser={currentUser?.employeeId} isVideoGallery={true} />
  380 + )}
294 </main> 381 </main>
295 382
296 {/* History Album (Bottom Left) */} 383 {/* History Album (Bottom Left) */}
297 <HistoryBar images={userHistory} onSelect={setSelectedImage} /> 384 <HistoryBar images={userHistory} onSelect={setSelectedImage} />
298 385
299 - <InputBar onGenerate={handleGenerate} isGenerating={isGenerating} incomingParams={incomingParams} /> 386 + <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} />
  387 +
300 388
301 {selectedImage && ( 389 {selectedImage && (
302 <DetailModal 390 <DetailModal
@@ -12,12 +12,14 @@ interface DetailModalProps { @@ -12,12 +12,14 @@ interface DetailModalProps {
12 const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGenerateSimilar }) => { 12 const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGenerateSimilar }) => {
13 if (!image) return null; 13 if (!image) return null;
14 14
  15 + const isVideo = image.id.startsWith('vid-');
  16 +
15 const copyToClipboard = (text: string) => { 17 const copyToClipboard = (text: string) => {
16 navigator.clipboard.writeText(text); 18 navigator.clipboard.writeText(text);
17 }; 19 };
18 20
19 const handleGenerateSimilar = () => { 21 const handleGenerateSimilar = () => {
20 - if (onGenerateSimilar) { 22 + if (onGenerateSimilar && !isVideo) {
21 const params: ImageGenerationParams = { 23 const params: ImageGenerationParams = {
22 prompt: image.prompt, 24 prompt: image.prompt,
23 width: image.width, 25 width: image.width,
@@ -39,7 +41,11 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen @@ -39,7 +41,11 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen
39 <button onClick={onClose} className="absolute top-4 right-4 z-10 p-2 bg-black/50 rounded-full text-white md:hidden"><X size={20} /></button> 41 <button onClick={onClose} className="absolute top-4 right-4 z-10 p-2 bg-black/50 rounded-full text-white md:hidden"><X size={20} /></button>
40 42
41 <div className="w-full md:w-2/3 bg-black flex items-center justify-center overflow-hidden h-[50vh] md:h-auto relative group"> 43 <div className="w-full md:w-2/3 bg-black flex items-center justify-center overflow-hidden h-[50vh] md:h-auto relative group">
  44 + {isVideo ? (
  45 + <video src={image.url} className="max-w-full max-h-full" controls autoPlay loop />
  46 + ) : (
42 <img src={image.url} alt={image.prompt} className="max-w-full max-h-full object-contain" /> 47 <img src={image.url} alt={image.prompt} className="max-w-full max-h-full object-contain" />
  48 + )}
43 </div> 49 </div>
44 50
45 <div className="w-full md:w-1/3 p-6 md:p-8 flex flex-col overflow-y-auto"> 51 <div className="w-full md:w-1/3 p-6 md:p-8 flex flex-col overflow-y-auto">
@@ -60,10 +66,12 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen @@ -60,10 +66,12 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen
60 </div> 66 </div>
61 67
62 <div className="space-y-6"> 68 <div className="space-y-6">
  69 + {!isVideo && (
63 <div className="flex items-center gap-2 text-red-500 font-medium"> 70 <div className="flex items-center gap-2 text-red-500 font-medium">
64 <Heart size={18} fill="currentColor" /> 71 <Heart size={18} fill="currentColor" />
65 <span>{image.likes || 0} Likes</span> 72 <span>{image.likes || 0} Likes</span>
66 </div> 73 </div>
  74 + )}
67 75
68 <div> 76 <div>
69 <label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">提示词 (Prompt)</label> 77 <label className="block text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">提示词 (Prompt)</label>
@@ -81,6 +89,7 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen @@ -81,6 +89,7 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen
81 </div> 89 </div>
82 </div> 90 </div>
83 91
  92 + {!isVideo && (
84 <div className="grid grid-cols-2 gap-4"> 93 <div className="grid grid-cols-2 gap-4">
85 <div> 94 <div>
86 <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">分辨率</label> 95 <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">分辨率</label>
@@ -99,8 +108,10 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen @@ -99,8 +108,10 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen
99 <p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p> 108 <p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p>
100 </div> 109 </div>
101 </div> 110 </div>
  111 + )}
102 112
103 <div className="pt-6 mt-auto space-y-3"> 113 <div className="pt-6 mt-auto space-y-3">
  114 + {!isVideo && onGenerateSimilar && (
104 <button 115 <button
105 onClick={handleGenerateSimilar} 116 onClick={handleGenerateSimilar}
106 className="flex items-center justify-center w-full py-3 bg-purple-600 text-white rounded-xl font-bold hover:bg-purple-700 transition-colors gap-2 shadow-lg shadow-purple-200 dark:shadow-none" 117 className="flex items-center justify-center w-full py-3 bg-purple-600 text-white rounded-xl font-bold hover:bg-purple-700 transition-colors gap-2 shadow-lg shadow-purple-200 dark:shadow-none"
@@ -108,16 +119,17 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen @@ -108,16 +119,17 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen
108 <Zap size={18} fill="currentColor" /> 119 <Zap size={18} fill="currentColor" />
109 生成同款 120 生成同款
110 </button> 121 </button>
  122 + )}
111 123
112 <a 124 <a
113 href={image.url} 125 href={image.url}
114 target="_blank" 126 target="_blank"
115 rel="noopener noreferrer" 127 rel="noopener noreferrer"
116 - download={`z-image-${image.id}.png`} 128 + download={`z-${isVideo ? 'video' : 'image'}-${image.id}.${isVideo ? 'mp4' : 'png'}`}
117 className="flex items-center justify-center w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-medium hover:opacity-90 transition-opacity gap-2" 129 className="flex items-center justify-center w-full py-3 bg-black dark:bg-white text-white dark:text-black rounded-xl font-medium hover:opacity-90 transition-opacity gap-2"
118 > 130 >
119 <Download size={18} /> 131 <Download size={18} />
120 - 下载原 132 + 下载原文件
121 </a> 133 </a>
122 </div> 134 </div>
123 </div> 135 </div>
1 -import React, { useState } from 'react'; 1 +import React, { useState, useRef, useEffect } from 'react';
2 import { ImageItem } from '../types'; 2 import { ImageItem } from '../types';
3 -import { Download, Heart } from 'lucide-react'; 3 +import { Download, Heart, Video } from 'lucide-react';
4 4
5 interface ImageCardProps { 5 interface ImageCardProps {
6 image: ImageItem; 6 image: ImageItem;
7 onClick: (image: ImageItem) => void; 7 onClick: (image: ImageItem) => void;
8 onLike: (image: ImageItem) => void; 8 onLike: (image: ImageItem) => void;
9 currentUser?: string; 9 currentUser?: string;
  10 + isVideo?: boolean;
10 } 11 }
11 12
12 -const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUser }) => { 13 +const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUser, isVideo = false }) => {
13 const [isLoaded, setIsLoaded] = useState(false); 14 const [isLoaded, setIsLoaded] = useState(false);
14 - const [isHovered, setIsHovered] = useState(false); 15 + const videoRef = useRef<HTMLVideoElement>(null);
15 16
16 const handleLike = (e: React.MouseEvent) => { 17 const handleLike = (e: React.MouseEvent) => {
17 e.stopPropagation(); 18 e.stopPropagation();
  19 + if (isVideo) return; // Liking is disabled for videos for now
18 onLike(image); 20 onLike(image);
19 }; 21 };
20 22
  23 + const handleMouseEnter = () => {
  24 + if (videoRef.current) {
  25 + videoRef.current.play().catch(e => console.error("Video play failed", e));
  26 + }
  27 + }
  28 +
  29 + const handleMouseLeave = () => {
  30 + if (videoRef.current) {
  31 + videoRef.current.pause();
  32 + }
  33 + }
  34 +
21 return ( 35 return (
22 <div 36 <div
23 className="group relative mb-4 break-inside-avoid rounded-2xl overflow-hidden bg-gray-200 cursor-zoom-in shadow-sm hover:shadow-xl transition-all duration-300" 37 className="group relative mb-4 break-inside-avoid rounded-2xl overflow-hidden bg-gray-200 cursor-zoom-in shadow-sm hover:shadow-xl transition-all duration-300"
24 onClick={() => onClick(image)} 38 onClick={() => onClick(image)}
25 - onMouseEnter={() => setIsHovered(true)}  
26 - onMouseLeave={() => setIsHovered(false)} 39 + onMouseEnter={handleMouseEnter}
  40 + onMouseLeave={handleMouseLeave}
27 > 41 >
28 {/* Placeholder / Skeleton */} 42 {/* Placeholder / Skeleton */}
29 {!isLoaded && ( 43 {!isLoaded && (
30 <div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" /> 44 <div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" />
31 )} 45 )}
32 46
33 - {/* Main Image */} 47 + {/* Main Content */}
  48 + {isVideo ? (
  49 + <video
  50 + ref={videoRef}
  51 + src={image.url}
  52 + className={`w-full h-auto object-cover transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
  53 + onLoadedData={() => setIsLoaded(true)}
  54 + loop
  55 + muted
  56 + playsInline
  57 + preload="metadata"
  58 + />
  59 + ) : (
34 <img 60 <img
35 src={image.url} 61 src={image.url}
36 alt={image.prompt} 62 alt={image.prompt}
@@ -38,6 +64,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -38,6 +64,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
38 onLoad={() => setIsLoaded(true)} 64 onLoad={() => setIsLoaded(true)}
39 loading="lazy" 65 loading="lazy"
40 /> 66 />
  67 + )}
41 68
42 {/* Hover Overlay */} 69 {/* Hover Overlay */}
43 <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4 md:p-5"> 70 <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex flex-col justify-end p-4 md:p-5">
@@ -51,7 +78,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -51,7 +78,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
51 e.stopPropagation(); 78 e.stopPropagation();
52 const link = document.createElement('a'); 79 const link = document.createElement('a');
53 link.href = image.url; 80 link.href = image.url;
54 - link.download = `z-image-${image.id}.png`; 81 + link.download = `z-${isVideo ? 'video' : 'image'}-${image.id}.${isVideo ? 'mp4' : 'png'}`;
55 document.body.appendChild(link); 82 document.body.appendChild(link);
56 link.click(); 83 link.click();
57 document.body.removeChild(link); 84 document.body.removeChild(link);
@@ -61,12 +88,19 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -61,12 +88,19 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
61 </button> 88 </button>
62 </div> 89 </div>
63 90
  91 + {isVideo && (
  92 + <div className="absolute top-4 left-4">
  93 + <Video size={16} className="text-white/80" />
  94 + </div>
  95 + )}
  96 +
64 {/* Content Info */} 97 {/* Content Info */}
65 <div className="text-white transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300"> 98 <div className="text-white transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
66 <p className="font-medium text-sm line-clamp-2 mb-2 text-shadow-sm"> 99 <p className="font-medium text-sm line-clamp-2 mb-2 text-shadow-sm">
67 {image.prompt} 100 {image.prompt}
68 </p> 101 </p>
69 102
  103 + {!isVideo && (
70 <div className="flex justify-between items-end"> 104 <div className="flex justify-between items-end">
71 {/* Author Info */} 105 {/* Author Info */}
72 <div className="text-xs text-gray-300 font-mono flex flex-col gap-1"> 106 <div className="text-xs text-gray-300 font-mono flex flex-col gap-1">
@@ -84,6 +118,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -84,6 +118,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
84 <span className="text-xs font-bold">{image.likes}</span> 118 <span className="text-xs font-bold">{image.likes}</span>
85 </button> 119 </button>
86 </div> 120 </div>
  121 + )}
87 </div> 122 </div>
88 </div> 123 </div>
89 </div> 124 </div>
1 -import React, { useState, useEffect, KeyboardEvent } from 'react';  
2 -import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw } from 'lucide-react'; 1 +import React, { useState, useEffect, KeyboardEvent, useRef } from 'react';
  2 +import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon } from 'lucide-react';
3 import { ImageGenerationParams } from '../types'; 3 import { ImageGenerationParams } from '../types';
4 4
5 interface InputBarProps { 5 interface InputBarProps {
6 - onGenerate: (params: ImageGenerationParams) => void; 6 + onGenerate: (params: ImageGenerationParams, imageFile?: File) => void;
7 isGenerating: boolean; 7 isGenerating: boolean;
8 - incomingParams?: ImageGenerationParams | null; // For "Generate Similar" 8 + incomingParams?: ImageGenerationParams | null;
  9 + isVideoMode: boolean;
9 } 10 }
10 11
11 const ASPECT_RATIOS = [ 12 const ASPECT_RATIOS = [
@@ -17,9 +18,12 @@ const ASPECT_RATIOS = [ @@ -17,9 +18,12 @@ const ASPECT_RATIOS = [
17 { label: 'Custom', w: 0, h: 0 }, 18 { label: 'Custom', w: 0, h: 0 },
18 ]; 19 ];
19 20
20 -const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams }) => { 21 +const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode }) => {
21 const [prompt, setPrompt] = useState(''); 22 const [prompt, setPrompt] = useState('');
22 const [showSettings, setShowSettings] = useState(false); 23 const [showSettings, setShowSettings] = useState(false);
  24 + const [imageFile, setImageFile] = useState<File | null>(null);
  25 + const [imagePreview, setImagePreview] = useState<string | null>(null);
  26 + const fileInputRef = useRef<HTMLInputElement>(null);
23 27
24 // Parameters State 28 // Parameters State
25 const [width, setWidth] = useState(1024); 29 const [width, setWidth] = useState(1024);
@@ -54,9 +58,26 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP @@ -54,9 +58,26 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
54 } 58 }
55 }, []); 59 }, []);
56 60
  61 + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  62 + const file = event.target.files?.[0];
  63 + if (file) {
  64 + setImageFile(file);
  65 + const reader = new FileReader();
  66 + reader.onloadend = () => {
  67 + setImagePreview(reader.result as string);
  68 + };
  69 + reader.readAsDataURL(file);
  70 + }
  71 + };
  72 +
57 const handleGenerate = () => { 73 const handleGenerate = () => {
58 if (!prompt.trim() || isGenerating) return; 74 if (!prompt.trim() || isGenerating) return;
59 75
  76 + if (isVideoMode && !imageFile) {
  77 + alert("请上传一张图片以生成视频。");
  78 + return;
  79 + }
  80 +
60 const params: ImageGenerationParams = { 81 const params: ImageGenerationParams = {
61 prompt, 82 prompt,
62 width, 83 width,
@@ -66,11 +87,7 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP @@ -66,11 +87,7 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
66 seed, 87 seed,
67 }; 88 };
68 89
69 - onGenerate(params);  
70 -  
71 - // Note: Prompt is NO LONGER cleared as per user request  
72 - // Regenerate seed for next time ONLY IF not explicit  
73 - // setSeed(Math.floor(Math.random() * 1000000)); 90 + onGenerate(params, imageFile || undefined);
74 }; 91 };
75 92
76 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { 93 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
@@ -226,13 +243,36 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP @@ -226,13 +243,36 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
226 <Sliders size={20} /> 243 <Sliders size={20} />
227 </button> 244 </button>
228 245
  246 + {isVideoMode && (
  247 + <div className="flex-shrink-0 ml-2">
  248 + <input
  249 + type="file"
  250 + ref={fileInputRef}
  251 + onChange={handleFileChange}
  252 + accept="image/*"
  253 + className="hidden"
  254 + />
  255 + <button
  256 + onClick={() => fileInputRef.current?.click()}
  257 + className="w-10 h-10 md:w-12 md:h-12 flex items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800 text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700 relative"
  258 + title="上传图片"
  259 + >
  260 + {imagePreview ? (
  261 + <img src={imagePreview} alt="Preview" className="w-full h-full object-cover rounded-full" />
  262 + ) : (
  263 + <ImageIcon size={20} />
  264 + )}
  265 + </button>
  266 + </div>
  267 + )}
  268 +
229 <input 269 <input
230 type="text" 270 type="text"
231 value={prompt} 271 value={prompt}
232 onChange={(e) => setPrompt(e.target.value)} 272 onChange={(e) => setPrompt(e.target.value)}
233 onKeyDown={handleKeyDown} 273 onKeyDown={handleKeyDown}
234 disabled={isGenerating} 274 disabled={isGenerating}
235 - placeholder="描述您的创意内容..." 275 + placeholder={isVideoMode ? "描述视频内容..." : "描述您的创意内容..."}
236 className="flex-grow bg-transparent border-none outline-none px-4 py-3 text-gray-800 dark:text-white placeholder-gray-400 text-base md:text-lg" 276 className="flex-grow bg-transparent border-none outline-none px-4 py-3 text-gray-800 dark:text-white placeholder-gray-400 text-base md:text-lg"
237 /> 277 />
238 278
@@ -250,10 +290,10 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP @@ -250,10 +290,10 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
250 290
251 <button 291 <button
252 onClick={handleGenerate} 292 onClick={handleGenerate}
253 - disabled={!prompt.trim() || isGenerating} 293 + disabled={!prompt.trim() || isGenerating || (isVideoMode && !imageFile)}
254 className={` 294 className={`
255 flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200 295 flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200
256 - ${prompt.trim() && !isGenerating 296 + ${(prompt.trim() && !isGenerating && (!isVideoMode || imageFile))
257 ? 'bg-black dark:bg-white text-white dark:text-black hover:scale-105 active:scale-95 shadow-md' 297 ? 'bg-black dark:bg-white text-white dark:text-black hover:scale-105 active:scale-95 shadow-md'
258 : 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed'} 298 : 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed'}
259 `} 299 `}
@@ -7,9 +7,10 @@ interface MasonryGridProps { @@ -7,9 +7,10 @@ interface MasonryGridProps {
7 onImageClick: (image: ImageItem) => void; 7 onImageClick: (image: ImageItem) => void;
8 onLike: (image: ImageItem) => void; 8 onLike: (image: ImageItem) => void;
9 currentUser?: string; 9 currentUser?: string;
  10 + isVideoGallery?: boolean;
10 } 11 }
11 12
12 -const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, currentUser }) => { 13 +const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, currentUser, isVideoGallery = false }) => {
13 return ( 14 return (
14 <div className="w-full px-4 md:px-8 py-6"> 15 <div className="w-full px-4 md:px-8 py-6">
15 <div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4"> 16 <div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
@@ -20,13 +21,14 @@ const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, @@ -20,13 +21,14 @@ const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike,
20 onClick={onImageClick} 21 onClick={onImageClick}
21 onLike={onLike} 22 onLike={onLike}
22 currentUser={currentUser} 23 currentUser={currentUser}
  24 + isVideo={isVideoGallery}
23 /> 25 />
24 ))} 26 ))}
25 </div> 27 </div>
26 28
27 {images.length === 0 && ( 29 {images.length === 0 && (
28 <div className="flex flex-col items-center justify-center h-64 text-gray-400"> 30 <div className="flex flex-col items-center justify-center h-64 text-gray-400">
29 - <p>暂无图片,快来生成第一张吧!</p> 31 + <p>暂无内容,快来生成第一个作品吧!</p>
30 </div> 32 </div>
31 )} 33 )}
32 </div> 34 </div>
1 import { ImageItem } from './types'; 1 import { ImageItem } from './types';
2 2
3 export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009"; 3 export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
  4 +export const TURBO_DIFFUSION_VIDEO_BASE_URL = "http://106.120.52.146:38000";
4 5
5 const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim(); 6 const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
6 const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0 7 const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
  1 +import { ImageGenerationParams } from './types';
  2 +
  3 +import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants';
  4 +
  5 +const pollStatus = async (taskId: string): Promise<void> => {
  6 + while (true) {
  7 + try {
  8 + const res = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/status/${taskId}`);
  9 + if (!res.ok) {
  10 + throw new Error(`HTTP error! status: ${res.status}`);
  11 + }
  12 + const data = await res.json();
  13 + if (data.status === 'complete') {
  14 + return;
  15 + } else if (data.status === 'failed') {
  16 + throw new Error(data.message || 'Video generation failed.');
  17 + }
  18 + // Wait for 2 seconds before polling again
  19 + await new Promise(resolve => setTimeout(resolve, 2000));
  20 + } catch (error) {
  21 + console.error('Polling error:', error);
  22 + throw error;
  23 + }
  24 + }
  25 +};
  26 +
  27 +export const generateVideo = async (prompt: string, image: File): Promise<string> => {
  28 + const formData = new FormData();
  29 + formData.append('prompt', prompt);
  30 + formData.append('image', image, image.name);
  31 +
  32 + // 1. Submit job
  33 + const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, {
  34 + method: 'POST',
  35 + body: formData,
  36 + });
  37 +
  38 + if (!submitRes.ok) {
  39 + const errorText = await submitRes.text();
  40 + throw new Error(`Job submission failed: ${errorText}`);
  41 + }
  42 +
  43 + const { task_id } = await submitRes.json();
  44 +
  45 + // 2. Poll for completion
  46 + await pollStatus(task_id);
  47 +
  48 + // 3. Return the result URL
  49 + return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${task_id}`;
  50 +};
  51 +
  52 +export const getVideoResultUrl = (taskId: string): string => {
  53 + return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`;
  54 +};
@@ -7,6 +7,7 @@ export interface ImageGenerationParams { @@ -7,6 +7,7 @@ export interface ImageGenerationParams {
7 seed: number; 7 seed: number;
8 output_format?: 'url' | 'base64'; 8 output_format?: 'url' | 'base64';
9 authorId?: string; 9 authorId?: string;
  10 + initImage?: File;
10 } 11 }
11 12
12 export interface ImageItem extends ImageGenerationParams { 13 export interface ImageItem extends ImageGenerationParams {