Showing
8 changed files
with
320 additions
and
87 deletions
| @@ -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 | ||
| 293 | - <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/> | 376 | + {galleryMode === GalleryMode.Image ? ( |
| 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"> |
| 42 | - <img src={image.url} alt={image.prompt} className="max-w-full max-h-full object-contain" /> | 44 | + {isVideo ? ( |
| 45 | + <video src={image.url} className="max-w-full max-h-full" controls autoPlay loop /> | ||
| 46 | + ) : ( | ||
| 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"> |
| 63 | - <div className="flex items-center gap-2 text-red-500 font-medium"> | ||
| 64 | - <Heart size={18} fill="currentColor" /> | ||
| 65 | - <span>{image.likes || 0} Likes</span> | ||
| 66 | - </div> | 69 | + {!isVideo && ( |
| 70 | + <div className="flex items-center gap-2 text-red-500 font-medium"> | ||
| 71 | + <Heart size={18} fill="currentColor" /> | ||
| 72 | + <span>{image.likes || 0} Likes</span> | ||
| 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,43 +89,47 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen | @@ -81,43 +89,47 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen | ||
| 81 | </div> | 89 | </div> |
| 82 | </div> | 90 | </div> |
| 83 | 91 | ||
| 84 | - <div className="grid grid-cols-2 gap-4"> | ||
| 85 | - <div> | ||
| 86 | - <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">分辨率</label> | ||
| 87 | - <p className="text-gray-800 dark:text-gray-200 font-mono">{image.width} x {image.height}</p> | ||
| 88 | - </div> | ||
| 89 | - <div> | ||
| 90 | - <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">随机种子</label> | ||
| 91 | - <p className="text-gray-800 dark:text-gray-200 font-mono">{image.seed}</p> | ||
| 92 | - </div> | ||
| 93 | - <div> | ||
| 94 | - <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">生成步数</label> | ||
| 95 | - <p className="text-gray-800 dark:text-gray-200 font-mono">{image.num_inference_steps}</p> | ||
| 96 | - </div> | ||
| 97 | - <div> | ||
| 98 | - <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">引导系数</label> | ||
| 99 | - <p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p> | ||
| 100 | - </div> | ||
| 101 | - </div> | 92 | + {!isVideo && ( |
| 93 | + <div className="grid grid-cols-2 gap-4"> | ||
| 94 | + <div> | ||
| 95 | + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">分辨率</label> | ||
| 96 | + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.width} x {image.height}</p> | ||
| 97 | + </div> | ||
| 98 | + <div> | ||
| 99 | + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">随机种子</label> | ||
| 100 | + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.seed}</p> | ||
| 101 | + </div> | ||
| 102 | + <div> | ||
| 103 | + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">生成步数</label> | ||
| 104 | + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.num_inference_steps}</p> | ||
| 105 | + </div> | ||
| 106 | + <div> | ||
| 107 | + <label className="block text-xs font-semibold text-gray-400 uppercase mb-1">引导系数</label> | ||
| 108 | + <p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p> | ||
| 109 | + </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"> |
| 104 | - <button | ||
| 105 | - 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" | ||
| 107 | - > | ||
| 108 | - <Zap size={18} fill="currentColor" /> | ||
| 109 | - 生成同款 | ||
| 110 | - </button> | 114 | + {!isVideo && onGenerateSimilar && ( |
| 115 | + <button | ||
| 116 | + onClick={handleGenerateSimilar} | ||
| 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" | ||
| 118 | + > | ||
| 119 | + <Zap size={18} fill="currentColor" /> | ||
| 120 | + 生成同款 | ||
| 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 | }; |
| 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 | + } | ||
| 20 | 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 */} | ||
| 34 | - <img | ||
| 35 | - src={image.url} | ||
| 36 | - alt={image.prompt} | ||
| 37 | - className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${isLoaded ? 'opacity-100' : 'opacity-0'}`} | ||
| 38 | - onLoad={() => setIsLoaded(true)} | ||
| 39 | - loading="lazy" | ||
| 40 | - /> | 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 | + ) : ( | ||
| 60 | + <img | ||
| 61 | + src={image.url} | ||
| 62 | + alt={image.prompt} | ||
| 63 | + className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${isLoaded ? 'opacity-100' : 'opacity-0'}`} | ||
| 64 | + onLoad={() => setIsLoaded(true)} | ||
| 65 | + loading="lazy" | ||
| 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); |
| @@ -60,6 +87,12 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | @@ -60,6 +87,12 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | ||
| 60 | <Download size={16} /> | 87 | <Download size={16} /> |
| 61 | </button> | 88 | </button> |
| 62 | </div> | 89 | </div> |
| 90 | + | ||
| 91 | + {isVideo && ( | ||
| 92 | + <div className="absolute top-4 left-4"> | ||
| 93 | + <Video size={16} className="text-white/80" /> | ||
| 94 | + </div> | ||
| 95 | + )} | ||
| 63 | 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"> |
| @@ -67,23 +100,25 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | @@ -67,23 +100,25 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | ||
| 67 | {image.prompt} | 100 | {image.prompt} |
| 68 | </p> | 101 | </p> |
| 69 | 102 | ||
| 70 | - <div className="flex justify-between items-end"> | ||
| 71 | - {/* Author Info */} | ||
| 72 | - <div className="text-xs text-gray-300 font-mono flex flex-col gap-1"> | ||
| 73 | - <span className="opacity-75">ID: {image.authorId || 'UNKNOWN'}</span> | ||
| 74 | - <span className="bg-white/10 px-1.5 py-0.5 rounded w-fit">{image.width}x{image.height}</span> | ||
| 75 | - </div> | 103 | + {!isVideo && ( |
| 104 | + <div className="flex justify-between items-end"> | ||
| 105 | + {/* Author Info */} | ||
| 106 | + <div className="text-xs text-gray-300 font-mono flex flex-col gap-1"> | ||
| 107 | + <span className="opacity-75">ID: {image.authorId || 'UNKNOWN'}</span> | ||
| 108 | + <span className="bg-white/10 px-1.5 py-0.5 rounded w-fit">{image.width}x{image.height}</span> | ||
| 109 | + </div> | ||
| 76 | 110 | ||
| 77 | - {/* Like Button */} | ||
| 78 | - <button | ||
| 79 | - onClick={handleLike} | ||
| 80 | - className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-200 ${image.isLikedByCurrentUser ? 'bg-red-500/80 text-white' : 'bg-white/20 text-white hover:bg-white/30'}`} | ||
| 81 | - title={currentUser ? "Like this image" : "Login to like"} | ||
| 82 | - > | ||
| 83 | - <Heart size={14} fill={image.isLikedByCurrentUser ? "currentColor" : "none"} /> | ||
| 84 | - <span className="text-xs font-bold">{image.likes}</span> | ||
| 85 | - </button> | ||
| 86 | - </div> | 111 | + {/* Like Button */} |
| 112 | + <button | ||
| 113 | + onClick={handleLike} | ||
| 114 | + className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full backdrop-blur-md transition-all duration-200 ${image.isLikedByCurrentUser ? 'bg-red-500/80 text-white' : 'bg-white/20 text-white hover:bg-white/30'}`} | ||
| 115 | + title={currentUser ? "Like this image" : "Login to like"} | ||
| 116 | + > | ||
| 117 | + <Heart size={14} fill={image.isLikedByCurrentUser ? "currentColor" : "none"} /> | ||
| 118 | + <span className="text-xs font-bold">{image.likes}</span> | ||
| 119 | + </button> | ||
| 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,10 +18,13 @@ const ASPECT_RATIOS = [ | @@ -17,10 +18,13 @@ 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); |
| 23 | - | 24 | + const [imageFile, setImageFile] = useState<File | null>(null); |
| 25 | + const [imagePreview, setImagePreview] = useState<string | null>(null); | ||
| 26 | + const fileInputRef = useRef<HTMLInputElement>(null); | ||
| 27 | + | ||
| 24 | // Parameters State | 28 | // Parameters State |
| 25 | const [width, setWidth] = useState(1024); | 29 | const [width, setWidth] = useState(1024); |
| 26 | const [height, setHeight] = useState(1024); | 30 | const [height, setHeight] = 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 |
z-image-generator/services/videoService.ts
0 → 100644
| 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 { |
-
Please register or login to post a comment