ly0303521

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

... ... @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ImageItem, ImageGenerationParams, UserProfile } from './types';
import { SHOWCASE_IMAGES, ADMIN_ID } from './constants';
import { generateImage } from './services/imageService';
import { generateVideo } from './services/videoService';
import { fetchGallery, toggleLike } from './services/galleryService';
import MasonryGrid from './components/MasonryGrid';
import InputBar from './components/InputBar';
... ... @@ -10,18 +11,28 @@ import DetailModal from './components/DetailModal';
import AdminModal from './components/AdminModal';
import AuthModal from './components/AuthModal';
import WhitelistModal from './components/WhitelistModal';
import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users } from 'lucide-react';
import { Loader2, Trash2, User as UserIcon, Save, Settings, Sparkles, Users, Video } from 'lucide-react';
const STORAGE_KEY_DATA = 'z-image-gallery-data-v2';
const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1';
const STORAGE_KEY_USER = 'z-image-user-profile';
const MIN_GALLERY_ITEMS = 8;
// --- Enums and Types ---
enum GalleryMode {
Image,
Video,
}
const App: React.FC = () => {
// --- State: User ---
const [currentUser, setCurrentUser] = useState<UserProfile | null>(null);
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false);
// --- State: UI ---
const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image);
// --- State: Data ---
const [images, setImages] = useState<ImageItem[]>(() => {
try {
... ... @@ -30,8 +41,17 @@ const App: React.FC = () => {
} catch (e) { console.error(e); }
return SHOWCASE_IMAGES;
});
const [videos, setVideos] = useState<ImageItem[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY_VIDEO_DATA);
if (saved) return JSON.parse(saved);
} catch(e) { console.error(e); }
return [];
});
const [isGenerating, setIsGenerating] = useState(false);
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null);
const [error, setError] = useState<string | null>(null);
const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null);
... ... @@ -66,6 +86,11 @@ const App: React.FC = () => {
catch (e) { console.error("Storage full", e); }
}, [images]);
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY_VIDEO_DATA, JSON.stringify(videos)); }
catch (e) { console.error("Storage full for videos", e); }
}, [videos]);
const handleLogin = (employeeId: string) => {
const user: UserProfile = { employeeId, hasAccess: true };
setCurrentUser(user);
... ... @@ -82,6 +107,10 @@ const App: React.FC = () => {
};
const syncGallery = useCallback(async () => {
if (galleryMode === GalleryMode.Video) {
// We are not syncing videos from a central gallery in this version.
return;
}
try {
const remoteImages = await fetchGallery();
setImages(prev => {
... ... @@ -113,7 +142,7 @@ const App: React.FC = () => {
console.error("Failed to sync gallery", err);
// Keep previous state if sync fails
}
}, [currentUser]);
}, [currentUser, galleryMode]);
useEffect(() => {
syncGallery();
... ... @@ -121,7 +150,43 @@ const App: React.FC = () => {
return () => clearInterval(interval);
}, [syncGallery]);
const handleGenerate = async (uiParams: ImageGenerationParams) => {
const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => {
if (!currentUser) {
setIsAuthModalOpen(true);
return;
}
setIsGeneratingVideo(true);
setError(null);
try {
const videoUrl = await generateVideo(params.prompt, imageFile);
const newVideo: ImageItem = {
id: `vid-${Date.now()}`,
url: videoUrl,
prompt: params.prompt,
authorId: currentUser.employeeId,
createdAt: Date.now(),
likes: 0,
isLikedByCurrentUser: false,
};
setVideos(prev => [newVideo, ...prev]);
} catch (err: any) {
console.error(err);
setError("视频生成失败。请确保视频生成服务正常运行。");
} finally {
setIsGeneratingVideo(false);
}
};
const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => {
if (galleryMode === GalleryMode.Video) {
if (!imageFile) {
alert("请上传一张图片以生成视频。");
return;
}
handleGenerateVideo(uiParams, imageFile);
return;
}
if (!currentUser) {
setIsAuthModalOpen(true);
return;
... ... @@ -278,25 +343,48 @@ export const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2
</div>
</header>
{/* --- Gallery Mode Tabs --- */}
<div className="px-6 md:px-12 mt-6 mb-4 flex border-b">
<button
onClick={() => setGalleryMode(GalleryMode.Image)}
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'}`}
>
灵感图库
</button>
<button
onClick={() => setGalleryMode(GalleryMode.Video)}
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'}`}
>
视频素材
</button>
</div>
{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>}
<main>
{isGenerating && (
{(isGenerating || isGeneratingVideo) && (
<div className="w-full flex justify-center py-12">
<div className="flex flex-col items-center animate-pulse">
<Loader2 className="animate-spin text-purple-600 mb-3" size={40} />
<span className="text-gray-500 font-medium">绘图引擎全力启动中...</span>
<span className="text-gray-500 font-medium">
{isGeneratingVideo ? "视频生成中,请耐心等待..." : "绘图引擎全力启动中..."}
</span>
</div>
</div>
)}
{galleryMode === GalleryMode.Image ? (
<MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/>
) : (
<MasonryGrid images={videos} onImageClick={setSelectedImage} onLike={() => {}} currentUser={currentUser?.employeeId} isVideoGallery={true} />
)}
</main>
{/* History Album (Bottom Left) */}
<HistoryBar images={userHistory} onSelect={setSelectedImage} />
<InputBar onGenerate={handleGenerate} isGenerating={isGenerating} incomingParams={incomingParams} />
<InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} />
{selectedImage && (
<DetailModal
... ...
... ... @@ -12,12 +12,14 @@ interface DetailModalProps {
const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGenerateSimilar }) => {
if (!image) return null;
const isVideo = image.id.startsWith('vid-');
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
};
const handleGenerateSimilar = () => {
if (onGenerateSimilar) {
if (onGenerateSimilar && !isVideo) {
const params: ImageGenerationParams = {
prompt: image.prompt,
width: image.width,
... ... @@ -39,7 +41,11 @@ const DetailModal: React.FC<DetailModalProps> = ({ image, onClose, onEdit, onGen
<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>
<div className="w-full md:w-2/3 bg-black flex items-center justify-center overflow-hidden h-[50vh] md:h-auto relative group">
{isVideo ? (
<video src={image.url} className="max-w-full max-h-full" controls autoPlay loop />
) : (
<img src={image.url} alt={image.prompt} className="max-w-full max-h-full object-contain" />
)}
</div>
<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
</div>
<div className="space-y-6">
{!isVideo && (
<div className="flex items-center gap-2 text-red-500 font-medium">
<Heart size={18} fill="currentColor" />
<span>{image.likes || 0} Likes</span>
</div>
)}
<div>
<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
</div>
</div>
{!isVideo && (
<div className="grid grid-cols-2 gap-4">
<div>
<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
<p className="text-gray-800 dark:text-gray-200 font-mono">{image.guidance_scale.toFixed(1)}</p>
</div>
</div>
)}
<div className="pt-6 mt-auto space-y-3">
{!isVideo && onGenerateSimilar && (
<button
onClick={handleGenerateSimilar}
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
<Zap size={18} fill="currentColor" />
生成同款
</button>
)}
<a
href={image.url}
target="_blank"
rel="noopener noreferrer"
download={`z-image-${image.id}.png`}
download={`z-${isVideo ? 'video' : 'image'}-${image.id}.${isVideo ? 'mp4' : 'png'}`}
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"
>
<Download size={18} />
下载原
下载原文件
</a>
</div>
</div>
... ...
import React, { useState } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { ImageItem } from '../types';
import { Download, Heart } from 'lucide-react';
import { Download, Heart, Video } from 'lucide-react';
interface ImageCardProps {
image: ImageItem;
onClick: (image: ImageItem) => void;
onLike: (image: ImageItem) => void;
currentUser?: string;
isVideo?: boolean;
}
const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUser }) => {
const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUser, isVideo = false }) => {
const [isLoaded, setIsLoaded] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const handleLike = (e: React.MouseEvent) => {
e.stopPropagation();
if (isVideo) return; // Liking is disabled for videos for now
onLike(image);
};
const handleMouseEnter = () => {
if (videoRef.current) {
videoRef.current.play().catch(e => console.error("Video play failed", e));
}
}
const handleMouseLeave = () => {
if (videoRef.current) {
videoRef.current.pause();
}
}
return (
<div
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"
onClick={() => onClick(image)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Placeholder / Skeleton */}
{!isLoaded && (
<div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" />
)}
{/* Main Image */}
{/* Main Content */}
{isVideo ? (
<video
ref={videoRef}
src={image.url}
className={`w-full h-auto object-cover transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoadedData={() => setIsLoaded(true)}
loop
muted
playsInline
preload="metadata"
/>
) : (
<img
src={image.url}
alt={image.prompt}
... ... @@ -38,6 +64,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
onLoad={() => setIsLoaded(true)}
loading="lazy"
/>
)}
{/* Hover Overlay */}
<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
e.stopPropagation();
const link = document.createElement('a');
link.href = image.url;
link.download = `z-image-${image.id}.png`;
link.download = `z-${isVideo ? 'video' : 'image'}-${image.id}.${isVideo ? 'mp4' : 'png'}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
... ... @@ -61,12 +88,19 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
</button>
</div>
{isVideo && (
<div className="absolute top-4 left-4">
<Video size={16} className="text-white/80" />
</div>
)}
{/* Content Info */}
<div className="text-white transform translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
<p className="font-medium text-sm line-clamp-2 mb-2 text-shadow-sm">
{image.prompt}
</p>
{!isVideo && (
<div className="flex justify-between items-end">
{/* Author Info */}
<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
<span className="text-xs font-bold">{image.likes}</span>
</button>
</div>
)}
</div>
</div>
</div>
... ...
import React, { useState, useEffect, KeyboardEvent } from 'react';
import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw } from 'lucide-react';
import React, { useState, useEffect, KeyboardEvent, useRef } from 'react';
import { Loader2, ArrowUp, Sliders, Dices, X, RefreshCw, Image as ImageIcon } from 'lucide-react';
import { ImageGenerationParams } from '../types';
interface InputBarProps {
onGenerate: (params: ImageGenerationParams) => void;
onGenerate: (params: ImageGenerationParams, imageFile?: File) => void;
isGenerating: boolean;
incomingParams?: ImageGenerationParams | null; // For "Generate Similar"
incomingParams?: ImageGenerationParams | null;
isVideoMode: boolean;
}
const ASPECT_RATIOS = [
... ... @@ -17,9 +18,12 @@ const ASPECT_RATIOS = [
{ label: 'Custom', w: 0, h: 0 },
];
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams }) => {
const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingParams, isVideoMode }) => {
const [prompt, setPrompt] = useState('');
const [showSettings, setShowSettings] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Parameters State
const [width, setWidth] = useState(1024);
... ... @@ -54,9 +58,26 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
}
}, []);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleGenerate = () => {
if (!prompt.trim() || isGenerating) return;
if (isVideoMode && !imageFile) {
alert("请上传一张图片以生成视频。");
return;
}
const params: ImageGenerationParams = {
prompt,
width,
... ... @@ -66,11 +87,7 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
seed,
};
onGenerate(params);
// Note: Prompt is NO LONGER cleared as per user request
// Regenerate seed for next time ONLY IF not explicit
// setSeed(Math.floor(Math.random() * 1000000));
onGenerate(params, imageFile || undefined);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
... ... @@ -226,13 +243,36 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
<Sliders size={20} />
</button>
{isVideoMode && (
<div className="flex-shrink-0 ml-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
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"
title="上传图片"
>
{imagePreview ? (
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover rounded-full" />
) : (
<ImageIcon size={20} />
)}
</button>
</div>
)}
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isGenerating}
placeholder="描述您的创意内容..."
placeholder={isVideoMode ? "描述视频内容..." : "描述您的创意内容..."}
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"
/>
... ... @@ -250,10 +290,10 @@ const InputBar: React.FC<InputBarProps> = ({ onGenerate, isGenerating, incomingP
<button
onClick={handleGenerate}
disabled={!prompt.trim() || isGenerating}
disabled={!prompt.trim() || isGenerating || (isVideoMode && !imageFile)}
className={`
flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full transition-all duration-200
${prompt.trim() && !isGenerating
${(prompt.trim() && !isGenerating && (!isVideoMode || imageFile))
? 'bg-black dark:bg-white text-white dark:text-black hover:scale-105 active:scale-95 shadow-md'
: 'bg-gray-200 dark:bg-gray-700 text-gray-400 cursor-not-allowed'}
`}
... ...
... ... @@ -7,9 +7,10 @@ interface MasonryGridProps {
onImageClick: (image: ImageItem) => void;
onLike: (image: ImageItem) => void;
currentUser?: string;
isVideoGallery?: boolean;
}
const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, currentUser }) => {
const MasonryGrid: React.FC<MasonryGridProps> = ({ images, onImageClick, onLike, currentUser, isVideoGallery = false }) => {
return (
<div className="w-full px-4 md:px-8 py-6">
<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,
onClick={onImageClick}
onLike={onLike}
currentUser={currentUser}
isVideo={isVideoGallery}
/>
))}
</div>
{images.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-gray-400">
<p>暂无图片,快来生成第一张吧!</p>
<p>暂无内容,快来生成第一个作品吧!</p>
</div>
)}
</div>
... ...
import { ImageItem } from './types';
export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009";
export const TURBO_DIFFUSION_VIDEO_BASE_URL = "http://106.120.52.146:38000";
const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim();
const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0
... ...
import { ImageGenerationParams } from './types';
import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants';
const pollStatus = async (taskId: string): Promise<void> => {
while (true) {
try {
const res = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/status/${taskId}`);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const data = await res.json();
if (data.status === 'complete') {
return;
} else if (data.status === 'failed') {
throw new Error(data.message || 'Video generation failed.');
}
// Wait for 2 seconds before polling again
await new Promise(resolve => setTimeout(resolve, 2000));
} catch (error) {
console.error('Polling error:', error);
throw error;
}
}
};
export const generateVideo = async (prompt: string, image: File): Promise<string> => {
const formData = new FormData();
formData.append('prompt', prompt);
formData.append('image', image, image.name);
// 1. Submit job
const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, {
method: 'POST',
body: formData,
});
if (!submitRes.ok) {
const errorText = await submitRes.text();
throw new Error(`Job submission failed: ${errorText}`);
}
const { task_id } = await submitRes.json();
// 2. Poll for completion
await pollStatus(task_id);
// 3. Return the result URL
return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${task_id}`;
};
export const getVideoResultUrl = (taskId: string): string => {
return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`;
};
... ...
... ... @@ -7,6 +7,7 @@ export interface ImageGenerationParams {
seed: number;
output_format?: 'url' | 'base64';
authorId?: string;
initImage?: File;
}
export interface ImageItem extends ImageGenerationParams {
... ...