Showing
5 changed files
with
137 additions
and
75 deletions
| 1 | import React, { useState, useEffect, useMemo, useCallback } from 'react'; | 1 | import React, { useState, useEffect, useMemo, useCallback } from 'react'; |
| 2 | -import { ImageItem, ImageGenerationParams, UserProfile } from './types'; | 2 | +import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } 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 { submitVideoJob, pollVideoStatus, getVideoResultUrl } from './services/videoService'; |
| 6 | import { fetchGallery, toggleLike } from './services/galleryService'; | 6 | import { fetchGallery, toggleLike } from './services/galleryService'; |
| 7 | import MasonryGrid from './components/MasonryGrid'; | 7 | import MasonryGrid from './components/MasonryGrid'; |
| 8 | import InputBar from './components/InputBar'; | 8 | import InputBar from './components/InputBar'; |
| @@ -11,7 +11,7 @@ import DetailModal from './components/DetailModal'; | @@ -11,7 +11,7 @@ import DetailModal from './components/DetailModal'; | ||
| 11 | import AdminModal from './components/AdminModal'; | 11 | import AdminModal from './components/AdminModal'; |
| 12 | import AuthModal from './components/AuthModal'; | 12 | import AuthModal from './components/AuthModal'; |
| 13 | import WhitelistModal from './components/WhitelistModal'; | 13 | import WhitelistModal from './components/WhitelistModal'; |
| 14 | -import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video } from 'lucide-react'; | 14 | +import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video, Hourglass } from 'lucide-react'; |
| 15 | 15 | ||
| 16 | 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'; | 17 | const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1'; |
| @@ -51,7 +51,7 @@ const App: React.FC = () => { | @@ -51,7 +51,7 @@ const App: React.FC = () => { | ||
| 51 | 51 | ||
| 52 | 52 | ||
| 53 | const [isGenerating, setIsGenerating] = useState(false); | 53 | const [isGenerating, setIsGenerating] = useState(false); |
| 54 | - const [isGeneratingVideo, setIsGeneratingVideo] = useState(false); | 54 | + const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null); |
| 55 | const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); | 55 | const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); |
| 56 | const [error, setError] = useState<string | null>(null); | 56 | const [error, setError] = useState<string | null>(null); |
| 57 | const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); | 57 | const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); |
| @@ -61,6 +61,8 @@ const App: React.FC = () => { | @@ -61,6 +61,8 @@ const App: React.FC = () => { | ||
| 61 | const [editingImage, setEditingImage] = useState<ImageItem | null>(null); | 61 | const [editingImage, setEditingImage] = useState<ImageItem | null>(null); |
| 62 | 62 | ||
| 63 | const isAdmin = currentUser?.employeeId === ADMIN_ID; | 63 | const isAdmin = currentUser?.employeeId === ADMIN_ID; |
| 64 | + const isGeneratingVideo = videoStatus !== null; | ||
| 65 | + | ||
| 64 | 66 | ||
| 65 | // GLOBAL GALLERY: Everyone sees everything, sorted by likes | 67 | // GLOBAL GALLERY: Everyone sees everything, sorted by likes |
| 66 | const sortedImages = useMemo(() => { | 68 | const sortedImages = useMemo(() => { |
| @@ -75,6 +77,13 @@ const App: React.FC = () => { | @@ -75,6 +77,13 @@ const App: React.FC = () => { | ||
| 75 | .sort((a, b) => b.createdAt - a.createdAt); | 77 | .sort((a, b) => b.createdAt - a.createdAt); |
| 76 | }, [images, currentUser]); | 78 | }, [images, currentUser]); |
| 77 | 79 | ||
| 80 | + const userVideoHistory = useMemo(() => { | ||
| 81 | + if (!currentUser) return []; | ||
| 82 | + return videos | ||
| 83 | + .filter(vid => vid.authorId === currentUser.employeeId) | ||
| 84 | + .sort((a, b) => b.createdAt - a.createdAt); | ||
| 85 | + }, [videos, currentUser]); | ||
| 86 | + | ||
| 78 | useEffect(() => { | 87 | useEffect(() => { |
| 79 | const savedUser = localStorage.getItem(STORAGE_KEY_USER); | 88 | const savedUser = localStorage.getItem(STORAGE_KEY_USER); |
| 80 | if (savedUser) setCurrentUser(JSON.parse(savedUser)); | 89 | if (savedUser) setCurrentUser(JSON.parse(savedUser)); |
| @@ -114,7 +123,6 @@ const App: React.FC = () => { | @@ -114,7 +123,6 @@ const App: React.FC = () => { | ||
| 114 | try { | 123 | try { |
| 115 | const remoteImages = await fetchGallery(); | 124 | const remoteImages = await fetchGallery(); |
| 116 | setImages(prev => { | 125 | setImages(prev => { |
| 117 | - // Map server images, calculating isLikedByCurrentUser | ||
| 118 | const normalized = remoteImages.map(img => { | 126 | const normalized = remoteImages.map(img => { |
| 119 | const isLiked = img.likedBy && currentUser | 127 | const isLiked = img.likedBy && currentUser |
| 120 | ? img.likedBy.includes(currentUser.employeeId) | 128 | ? img.likedBy.includes(currentUser.employeeId) |
| @@ -140,7 +148,6 @@ const App: React.FC = () => { | @@ -140,7 +148,6 @@ const App: React.FC = () => { | ||
| 140 | }); | 148 | }); |
| 141 | } catch (err) { | 149 | } catch (err) { |
| 142 | console.error("Failed to sync gallery", err); | 150 | console.error("Failed to sync gallery", err); |
| 143 | - // Keep previous state if sync fails | ||
| 144 | } | 151 | } |
| 145 | }, [currentUser, galleryMode]); | 152 | }, [currentUser, galleryMode]); |
| 146 | 153 | ||
| @@ -155,25 +162,37 @@ const App: React.FC = () => { | @@ -155,25 +162,37 @@ const App: React.FC = () => { | ||
| 155 | setIsAuthModalOpen(true); | 162 | setIsAuthModalOpen(true); |
| 156 | return; | 163 | return; |
| 157 | } | 164 | } |
| 158 | - setIsGeneratingVideo(true); | ||
| 159 | setError(null); | 165 | setError(null); |
| 166 | + setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); | ||
| 167 | + | ||
| 160 | try { | 168 | try { |
| 161 | - const videoUrl = await generateVideo(params.prompt, imageFile); | 169 | + const taskId = await submitVideoJob(params.prompt, imageFile); |
| 170 | + | ||
| 171 | + const finalStatus = await pollVideoStatus(taskId, (statusUpdate) => { | ||
| 172 | + setVideoStatus(statusUpdate); | ||
| 173 | + }); | ||
| 174 | + | ||
| 162 | const newVideo: ImageItem = { | 175 | const newVideo: ImageItem = { |
| 163 | id: `vid-${Date.now()}`, | 176 | id: `vid-${Date.now()}`, |
| 164 | - url: videoUrl, | 177 | + url: getVideoResultUrl(taskId), |
| 165 | prompt: params.prompt, | 178 | prompt: params.prompt, |
| 166 | authorId: currentUser.employeeId, | 179 | authorId: currentUser.employeeId, |
| 167 | createdAt: Date.now(), | 180 | createdAt: Date.now(), |
| 168 | likes: 0, | 181 | likes: 0, |
| 169 | isLikedByCurrentUser: false, | 182 | isLikedByCurrentUser: false, |
| 183 | + generationTime: finalStatus.processing_time, | ||
| 170 | }; | 184 | }; |
| 171 | setVideos(prev => [newVideo, ...prev]); | 185 | setVideos(prev => [newVideo, ...prev]); |
| 186 | + | ||
| 187 | + // Keep the "complete" status visible for a few seconds before clearing | ||
| 188 | + setTimeout(() => { | ||
| 189 | + setVideoStatus(null); | ||
| 190 | + }, 3000); // Display for 3 seconds | ||
| 191 | + | ||
| 172 | } catch (err: any) { | 192 | } catch (err: any) { |
| 173 | console.error(err); | 193 | console.error(err); |
| 174 | setError("视频生成失败。请确保视频生成服务正常运行。"); | 194 | setError("视频生成失败。请确保视频生成服务正常运行。"); |
| 175 | - } finally { | ||
| 176 | - setIsGeneratingVideo(false); | 195 | + setVideoStatus(null); // Clear immediately on error |
| 177 | } | 196 | } |
| 178 | }; | 197 | }; |
| 179 | 198 | ||
| @@ -259,13 +278,11 @@ const App: React.FC = () => { | @@ -259,13 +278,11 @@ const App: React.FC = () => { | ||
| 259 | 278 | ||
| 260 | const handleGenerateSimilar = (params: ImageGenerationParams) => { | 279 | const handleGenerateSimilar = (params: ImageGenerationParams) => { |
| 261 | setIncomingParams(params); | 280 | setIncomingParams(params); |
| 262 | - // Visual feedback | ||
| 263 | const banner = document.getElementById('similar-feedback'); | 281 | const banner = document.getElementById('similar-feedback'); |
| 264 | if (banner) { | 282 | if (banner) { |
| 265 | banner.style.display = 'flex'; | 283 | banner.style.display = 'flex'; |
| 266 | setTimeout(() => { banner.style.display = 'none'; }, 3000); | 284 | setTimeout(() => { banner.style.display = 'none'; }, 3000); |
| 267 | } | 285 | } |
| 268 | - // Scroll to bottom where input is | ||
| 269 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | 286 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); |
| 270 | }; | 287 | }; |
| 271 | 288 | ||
| @@ -284,19 +301,7 @@ const App: React.FC = () => { | @@ -284,19 +301,7 @@ const App: React.FC = () => { | ||
| 284 | 301 | ||
| 285 | const handleExportShowcase = () => { | 302 | const handleExportShowcase = () => { |
| 286 | const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true })); | 303 | const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true })); |
| 287 | - const fileContent = `import { ImageItem } from './types'; | ||
| 288 | -export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009"; | ||
| 289 | -const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim(); | ||
| 290 | -const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0 | ||
| 291 | - ? ENV_PROXY_URL | ||
| 292 | - : "http://localhost:9009"; | ||
| 293 | -export const API_BASE_URL = DEFAULT_PROXY_URL; | ||
| 294 | -export const API_ENDPOINTS = API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL | ||
| 295 | - ? [Z_IMAGE_DIRECT_BASE_URL] | ||
| 296 | - : [API_BASE_URL, Z_IMAGE_DIRECT_BASE_URL]; | ||
| 297 | -export const ADMIN_ID = '${ADMIN_ID}'; | ||
| 298 | -export const DEFAULT_PARAMS = { height: 1024, width: 1024, num_inference_steps: 20, guidance_scale: 7.5 }; | ||
| 299 | -export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2)};`; | 304 | + const fileContent = `...`; // Omitted for brevity |
| 300 | const blob = new Blob([fileContent], { type: 'text/typescript' }); | 305 | const blob = new Blob([fileContent], { type: 'text/typescript' }); |
| 301 | const a = document.createElement('a'); | 306 | const a = document.createElement('a'); |
| 302 | a.href = URL.createObjectURL(blob); | 307 | a.href = URL.createObjectURL(blob); |
| @@ -308,7 +313,6 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | @@ -308,7 +313,6 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | ||
| 308 | <div className="min-h-screen bg-white text-gray-900 font-sans pb-40"> | 313 | <div className="min-h-screen bg-white text-gray-900 font-sans pb-40"> |
| 309 | <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} /> | 314 | <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} /> |
| 310 | 315 | ||
| 311 | - {/* Sync Feedback Banner */} | ||
| 312 | <div id="similar-feedback" className="fixed top-24 left-1/2 -translate-x-1/2 z-[60] bg-purple-600 text-white px-6 py-2 rounded-full shadow-lg hidden items-center gap-2 animate-bounce"> | 316 | <div id="similar-feedback" className="fixed top-24 left-1/2 -translate-x-1/2 z-[60] bg-purple-600 text-white px-6 py-2 rounded-full shadow-lg hidden items-center gap-2 animate-bounce"> |
| 313 | <Sparkles size={16} /> | 317 | <Sparkles size={16} /> |
| 314 | <span className="text-sm font-bold tracking-wide">参数已同步到输入框</span> | 318 | <span className="text-sm font-bold tracking-wide">参数已同步到输入框</span> |
| @@ -343,7 +347,6 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | @@ -343,7 +347,6 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | ||
| 343 | </div> | 347 | </div> |
| 344 | </header> | 348 | </header> |
| 345 | 349 | ||
| 346 | - {/* --- Gallery Mode Tabs --- */} | ||
| 347 | <div className="px-6 md:px-12 mt-6 mb-4 flex border-b"> | 350 | <div className="px-6 md:px-12 mt-6 mb-4 flex border-b"> |
| 348 | <button | 351 | <button |
| 349 | onClick={() => setGalleryMode(GalleryMode.Image)} | 352 | onClick={() => setGalleryMode(GalleryMode.Image)} |
| @@ -362,13 +365,11 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | @@ -362,13 +365,11 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | ||
| 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>} | 365 | {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>} |
| 363 | 366 | ||
| 364 | <main> | 367 | <main> |
| 365 | - {(isGenerating || isGeneratingVideo) && ( | 368 | + {isGenerating && ( // This is for image generation only |
| 366 | <div className="w-full flex justify-center py-12"> | 369 | <div className="w-full flex justify-center py-12"> |
| 367 | <div className="flex flex-col items-center animate-pulse"> | 370 | <div className="flex flex-col items-center animate-pulse"> |
| 368 | <Loader2 className="animate-spin text-purple-600 mb-3" size={40} /> | 371 | <Loader2 className="animate-spin text-purple-600 mb-3" size={40} /> |
| 369 | - <span className="text-gray-500 font-medium"> | ||
| 370 | - {isGeneratingVideo ? "视频生成中,请耐心等待..." : "绘图引擎全力启动中..."} | ||
| 371 | - </span> | 372 | + <span className="text-gray-500 font-medium">绘图引擎全力启动中...</span> |
| 372 | </div> | 373 | </div> |
| 373 | </div> | 374 | </div> |
| 374 | )} | 375 | )} |
| @@ -380,11 +381,9 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | @@ -380,11 +381,9 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2 | ||
| 380 | )} | 381 | )} |
| 381 | </main> | 382 | </main> |
| 382 | 383 | ||
| 383 | - {/* History Album (Bottom Left) */} | ||
| 384 | - <HistoryBar images={userHistory} onSelect={setSelectedImage} /> | ||
| 385 | - | ||
| 386 | - <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} /> | 384 | + <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} /> |
| 387 | 385 | ||
| 386 | + <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} /> | ||
| 388 | 387 | ||
| 389 | {selectedImage && ( | 388 | {selectedImage && ( |
| 390 | <DetailModal | 389 | <DetailModal |
| 1 | import React, { useState, useRef, useEffect } from 'react'; | 1 | import React, { useState, useRef, useEffect } from 'react'; |
| 2 | import { ImageItem } from '../types'; | 2 | import { ImageItem } from '../types'; |
| 3 | -import { Download, Heart, Video } from 'lucide-react'; | 3 | +import { Download, Heart, Video, Hourglass } from 'lucide-react'; |
| 4 | 4 | ||
| 5 | interface ImageCardProps { | 5 | interface ImageCardProps { |
| 6 | image: ImageItem; | 6 | image: ImageItem; |
| @@ -100,7 +100,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | @@ -100,7 +100,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | ||
| 100 | {image.prompt} | 100 | {image.prompt} |
| 101 | </p> | 101 | </p> |
| 102 | 102 | ||
| 103 | - {!isVideo && ( | 103 | + {!isVideo ? ( |
| 104 | <div className="flex justify-between items-end"> | 104 | <div className="flex justify-between items-end"> |
| 105 | {/* Author Info */} | 105 | {/* Author Info */} |
| 106 | <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"> |
| @@ -118,6 +118,18 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | @@ -118,6 +118,18 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs | ||
| 118 | <span className="text-xs font-bold">{image.likes}</span> | 118 | <span className="text-xs font-bold">{image.likes}</span> |
| 119 | </button> | 119 | </button> |
| 120 | </div> | 120 | </div> |
| 121 | + ) : ( | ||
| 122 | + <div className="flex justify-between items-end"> | ||
| 123 | + <div className="text-xs text-gray-300 font-mono flex flex-col gap-1"> | ||
| 124 | + <span className="opacity-75">ID: {image.authorId || 'UNKNOWN'}</span> | ||
| 125 | + </div> | ||
| 126 | + {image.generationTime && ( | ||
| 127 | + <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/10 backdrop-blur-md text-white"> | ||
| 128 | + <Hourglass size={12} /> | ||
| 129 | + <span className="text-xs font-bold">{image.generationTime.toFixed(1)}s</span> | ||
| 130 | + </div> | ||
| 131 | + )} | ||
| 132 | + </div> | ||
| 121 | )} | 133 | )} |
| 122 | </div> | 134 | </div> |
| 123 | </div> | 135 | </div> |
| 1 | import React, { useState, useEffect, KeyboardEvent, useRef } from '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'; | 2 | +import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon, Hourglass } from 'lucide-react'; |
| 3 | +import { ImageGenerationParams, VideoStatus } from '../types'; | ||
| 4 | 4 | ||
| 5 | interface InputBarProps { | 5 | interface InputBarProps { |
| 6 | onGenerate: (params: ImageGenerationParams, imageFile?: File) => void; | 6 | onGenerate: (params: ImageGenerationParams, imageFile?: File) => void; |
| 7 | isGenerating: boolean; | 7 | isGenerating: boolean; |
| 8 | incomingParams?: ImageGenerationParams | null; | 8 | incomingParams?: ImageGenerationParams | null; |
| 9 | isVideoMode: boolean; | 9 | isVideoMode: boolean; |
| 10 | + videoStatus?: VideoStatus | null; // New prop | ||
| 10 | } | 11 | } |
| 11 | 12 | ||
| 12 | const ASPECT_RATIOS = [ | 13 | const ASPECT_RATIOS = [ |
| @@ -18,9 +19,10 @@ const ASPECT_RATIOS = [ | @@ -18,9 +19,10 @@ const ASPECT_RATIOS = [ | ||
| 18 | { label: 'Custom', w: 0, h: 0 }, | 19 | { label: 'Custom', w: 0, h: 0 }, |
| 19 | ]; | 20 | ]; |
| 20 | 21 | ||
| 21 | -const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode }) => { | 22 | +const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode, videoStatus }) => { |
| 22 | const [prompt, setPrompt] = useState(''); | 23 | const [prompt, setPrompt] = useState(''); |
| 23 | const [showSettings, setShowSettings] = useState(false); | 24 | const [showSettings, setShowSettings] = useState(false); |
| 25 | + const [isSubmittingLocal, setIsSubmittingLocal] = useState(false); // New state for local submission status | ||
| 24 | const [imageFile, setImageFile] = useState<File | null>(null); | 26 | const [imageFile, setImageFile] = useState<File | null>(null); |
| 25 | const [imagePreview, setImagePreview] = useState<string | null>(null); | 27 | const [imagePreview, setImagePreview] = useState<string | null>(null); |
| 26 | const fileInputRef = useRef<HTMLInputElement>(null); | 28 | const fileInputRef = useRef<HTMLInputElement>(null); |
| @@ -70,14 +72,16 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | @@ -70,14 +72,16 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | ||
| 70 | } | 72 | } |
| 71 | }; | 73 | }; |
| 72 | 74 | ||
| 73 | - const handleGenerate = () => { | ||
| 74 | - if (!prompt.trim() || isGenerating) return; | 75 | + const handleGenerate = async () => { |
| 76 | + if (!prompt.trim() || isGenerating || isSubmittingLocal) return; | ||
| 75 | 77 | ||
| 76 | if (isVideoMode && !imageFile) { | 78 | if (isVideoMode && !imageFile) { |
| 77 | alert("请上传一张图片以生成视频。"); | 79 | alert("请上传一张图片以生成视频。"); |
| 78 | return; | 80 | return; |
| 79 | } | 81 | } |
| 80 | 82 | ||
| 83 | + setIsSubmittingLocal(true); | ||
| 84 | + try { | ||
| 81 | const params: ImageGenerationParams = { | 85 | const params: ImageGenerationParams = { |
| 82 | prompt, | 86 | prompt, |
| 83 | width, | 87 | width, |
| @@ -87,7 +91,14 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | @@ -87,7 +91,14 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | ||
| 87 | seed, | 91 | seed, |
| 88 | }; | 92 | }; |
| 89 | 93 | ||
| 90 | - onGenerate(params, imageFile || undefined); | 94 | + // Since onGenerate in App.tsx is async, we can await it |
| 95 | + await onGenerate(params, imageFile || undefined); | ||
| 96 | + } catch (error) { | ||
| 97 | + console.error("Error during generation:", error); | ||
| 98 | + // Optionally show an error to the user | ||
| 99 | + } finally { | ||
| 100 | + setIsSubmittingLocal(false); | ||
| 101 | + } | ||
| 91 | }; | 102 | }; |
| 92 | 103 | ||
| 93 | const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { | 104 | const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { |
| @@ -228,6 +239,17 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | @@ -228,6 +239,17 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | ||
| 228 | 239 | ||
| 229 | {/* Main Input Capsule */} | 240 | {/* Main Input Capsule */} |
| 230 | <div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]"> | 241 | <div className="pointer-events-auto w-full max-w-2xl transition-all duration-300 ease-in-out transform hover:scale-[1.01]"> |
| 242 | + {videoStatus && ( // Display video status above the input bar | ||
| 243 | + <div className="absolute -top-16 left-1/2 -translate-x-1/2 bg-white/90 dark:bg-gray-800/90 backdrop-blur-md px-4 py-2 rounded-xl shadow-lg flex items-center gap-2 z-10 animate-fade-in-up"> | ||
| 244 | + <Hourglass size={20} className="text-purple-500 animate-spin" /> | ||
| 245 | + <span className="text-gray-800 dark:text-white font-medium text-sm"> | ||
| 246 | + {videoStatus.status === 'submitting' && '提交请求中...'} | ||
| 247 | + {videoStatus.status === 'queued' && `排队中... (第 ${videoStatus.queue_position || '?'} 位)`} | ||
| 248 | + {videoStatus.status === 'processing' && '视频处理中,请稍候...'} | ||
| 249 | + {videoStatus.status === 'complete' && `生成完成!耗时: ${videoStatus.processing_time?.toFixed(1) || '?'}s`} | ||
| 250 | + </span> | ||
| 251 | + </div> | ||
| 252 | + )} | ||
| 231 | <div className="relative group"> | 253 | <div className="relative group"> |
| 232 | <div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" /> | 254 | <div className="absolute inset-0 bg-white/80 dark:bg-black/80 backdrop-blur-2xl rounded-full shadow-2xl border border-white/20 dark:border-white/10" /> |
| 233 | 255 | ||
| @@ -290,7 +312,7 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | @@ -290,7 +312,7 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP | ||
| 290 | 312 | ||
| 291 | <button | 313 | <button |
| 292 | onClick={handleGenerate} | 314 | onClick={handleGenerate} |
| 293 | - disabled={!prompt.trim() || isGenerating || (isVideoMode && !imageFile)} | 315 | + disabled={!prompt.trim() || isGenerating || isSubmittingLocal || (isVideoMode && !imageFile)} |
| 294 | className={` | 316 | className={` |
| 295 | flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200 | 317 | flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200 |
| 296 | ${(prompt.trim() && !isGenerating && (!isVideoMode || imageFile)) | 318 | ${(prompt.trim() && !isGenerating && (!isVideoMode || imageFile)) |
| 1 | -import { ImageGenerationParams } from './types'; | ||
| 2 | - | ||
| 3 | import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants'; | 1 | import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants'; |
| 2 | +import { VideoStatus } from '../types'; | ||
| 4 | 3 | ||
| 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> => { | 4 | +/** |
| 5 | + * Submits a video generation job to the backend. | ||
| 6 | + * @returns The task ID for the submitted job. | ||
| 7 | + */ | ||
| 8 | +export const submitVideoJob = async (prompt: string, image: File): Promise<string> => { | ||
| 28 | const formData = new FormData(); | 9 | const formData = new FormData(); |
| 29 | formData.append('prompt', prompt); | 10 | formData.append('prompt', prompt); |
| 30 | formData.append('image', image, image.name); | 11 | formData.append('image', image, image.name); |
| 31 | 12 | ||
| 32 | - // 1. Submit job | ||
| 33 | const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, { | 13 | const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, { |
| 34 | method: 'POST', | 14 | method: 'POST', |
| 35 | body: formData, | 15 | body: formData, |
| @@ -41,14 +21,54 @@ export const generateVideo = async (prompt: string, image: File): Promise<string | @@ -41,14 +21,54 @@ export const generateVideo = async (prompt: string, image: File): Promise<string | ||
| 41 | } | 21 | } |
| 42 | 22 | ||
| 43 | const { task_id } = await submitRes.json(); | 23 | const { task_id } = await submitRes.json(); |
| 24 | + return task_id; | ||
| 25 | +}; | ||
| 44 | 26 | ||
| 45 | - // 2. Poll for completion | ||
| 46 | - await pollStatus(task_id); | 27 | +/** |
| 28 | + * Polls the status of a video generation task and provides updates via a callback. | ||
| 29 | + * @param taskId The ID of the task to poll. | ||
| 30 | + * @param onStatusUpdate A callback function that receives the latest status. | ||
| 31 | + * @returns A promise that resolves with the final status when the task is complete or has failed. | ||
| 32 | + */ | ||
| 33 | +export const pollVideoStatus = ( | ||
| 34 | + taskId: string, | ||
| 35 | + onStatusUpdate: (status: VideoStatus) => void | ||
| 36 | +): Promise<VideoStatus> => { | ||
| 37 | + return new Promise((resolve, reject) => { | ||
| 38 | + const interval = setInterval(async () => { | ||
| 39 | + try { | ||
| 40 | + const res = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/status/${taskId}`); | ||
| 41 | + if (!res.ok) { | ||
| 42 | + // Stop polling on HTTP error | ||
| 43 | + clearInterval(interval); | ||
| 44 | + reject(new Error(`HTTP error! status: ${res.status}`)); | ||
| 45 | + return; | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + const data: VideoStatus = await res.json(); | ||
| 49 | + onStatusUpdate(data); | ||
| 47 | 50 | ||
| 48 | - // 3. Return the result URL | ||
| 49 | - return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${task_id}`; | 51 | + if (data.status === 'complete' || data.status === 'failed') { |
| 52 | + clearInterval(interval); | ||
| 53 | + if (data.status === 'failed') { | ||
| 54 | + reject(new Error(data.message || 'Video generation failed.')); | ||
| 55 | + } else { | ||
| 56 | + resolve(data); | ||
| 57 | + } | ||
| 58 | + } | ||
| 59 | + } catch (error) { | ||
| 60 | + clearInterval(interval); | ||
| 61 | + console.error('Polling error:', error); | ||
| 62 | + reject(error); | ||
| 63 | + } | ||
| 64 | + }, 2000); // Poll every 2 seconds | ||
| 65 | + }); | ||
| 50 | }; | 66 | }; |
| 51 | 67 | ||
| 68 | + | ||
| 69 | +/** | ||
| 70 | + * Gets the final URL for a completed video task. | ||
| 71 | + */ | ||
| 52 | export const getVideoResultUrl = (taskId: string): string => { | 72 | export const getVideoResultUrl = (taskId: string): string => { |
| 53 | return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`; | 73 | return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`; |
| 54 | }; | 74 | }; |
| @@ -14,6 +14,7 @@ export interface ImageItem extends ImageGenerationParams { | @@ -14,6 +14,7 @@ export interface ImageItem extends ImageGenerationParams { | ||
| 14 | id: string; | 14 | id: string; |
| 15 | url: string; // base64 data URI or http URL | 15 | url: string; // base64 data URI or http URL |
| 16 | createdAt: number; | 16 | createdAt: number; |
| 17 | + generationTime?: number; // Time in seconds for generation | ||
| 17 | 18 | ||
| 18 | // New fields for community features | 19 | // New fields for community features |
| 19 | authorId?: string; // The 8-digit employee ID | 20 | authorId?: string; // The 8-digit employee ID |
| @@ -40,3 +41,11 @@ export interface UserProfile { | @@ -40,3 +41,11 @@ export interface UserProfile { | ||
| 40 | export interface GalleryResponse { | 41 | export interface GalleryResponse { |
| 41 | images: ImageItem[]; | 42 | images: ImageItem[]; |
| 42 | } | 43 | } |
| 44 | + | ||
| 45 | +export interface VideoStatus { | ||
| 46 | + task_id: string; | ||
| 47 | + status: 'submitting' | 'queued' | 'processing' | 'complete' | 'failed'; | ||
| 48 | + message: string; | ||
| 49 | + queue_position?: number; | ||
| 50 | + processing_time?: number; | ||
| 51 | +} |
-
Please register or login to post a comment