ly0303521

视频加载使用缩略图

@@ -14,6 +14,8 @@ from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form @@ -14,6 +14,8 @@ from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
14 from fastapi.middleware.cors import CORSMiddleware 14 from fastapi.middleware.cors import CORSMiddleware
15 from pydantic import BaseModel, Field, ConfigDict 15 from pydantic import BaseModel, Field, ConfigDict
16 import logging 16 import logging
  17 +from PIL import Image
  18 +import io
17 19
18 # --- Constants --- 20 # --- Constants ---
19 logger = logging.getLogger("uvicorn.error") 21 logger = logging.getLogger("uvicorn.error")
@@ -175,6 +177,7 @@ class GalleryVideo(GalleryItem): @@ -175,6 +177,7 @@ class GalleryVideo(GalleryItem):
175 seed: Optional[int] = Field(default=None, ge=0) 177 seed: Optional[int] = Field(default=None, ge=0)
176 width: int = Field(1024, ge=64, le=2048) 178 width: int = Field(1024, ge=64, le=2048)
177 height: int = Field(1024, ge=64, le=2048) 179 height: int = Field(1024, ge=64, le=2048)
  180 + thumbnail: Optional[str] = None
178 181
179 class ImageGenerationResponse(BaseModel): 182 class ImageGenerationResponse(BaseModel):
180 image: Optional[str] = None 183 image: Optional[str] = None
@@ -310,6 +313,10 @@ usage_store = UsageStore(USAGE_PATH) @@ -310,6 +313,10 @@ usage_store = UsageStore(USAGE_PATH)
310 313
311 # --- App Setup --- 314 # --- App Setup ---
312 app = FastAPI(title="Z-Image Proxy", version="1.0.0") 315 app = FastAPI(title="Z-Image Proxy", version="1.0.0")
  316 +from fastapi.staticfiles import StaticFiles
  317 +# Mount public directory to serve thumbnails and frontend config
  318 +app.mount("/thumbnails", StaticFiles(directory=str(Path(__file__).parent.parent / "public" / "thumbnails")), name="thumbnails")
  319 +
313 app.add_middleware( 320 app.add_middleware(
314 CORSMiddleware, 321 CORSMiddleware,
315 allow_origins=["*"], 322 allow_origins=["*"],
@@ -402,6 +409,46 @@ async def submit_video_job_proxy( @@ -402,6 +409,46 @@ async def submit_video_job_proxy(
402 409
403 # Prepare files and data for forwarding 410 # Prepare files and data for forwarding
404 file_content = await image.read() 411 file_content = await image.read()
  412 +
  413 + # --- Save Thumbnail ---
  414 + # Generate a lightweight thumbnail (max 400px width/height, ~20KB)
  415 + thumbnail_filename = f"thumb_{secrets.token_hex(8)}.jpg" # Always use jpg for efficiency
  416 +
  417 + # Updated Path: Save to OSS directory
  418 + OSS_THUMBNAIL_DIR = Path("/home/inspur/work_space/gen_img_video/TurboDiffusion-Space/ASSERT/艺云-DESIGN/thumbnails")
  419 + thumbnail_path = OSS_THUMBNAIL_DIR / thumbnail_filename
  420 +
  421 + # Updated URL: Construct from environment variables (PUBLIC_IP and PUBLIC_OSS_PORT)
  422 + public_ip = os.getenv("PUBLIC_IP", "106.120.52.146")
  423 + oss_port = os.getenv("PUBLIC_OSS_PORT", "34000")
  424 + oss_base_url = f"http://{public_ip}:{oss_port}"
  425 + thumbnail_url_path = f"{oss_base_url}/thumbnails/{thumbnail_filename}"
  426 +
  427 + try:
  428 + thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
  429 + # Use PIL to resize and compress
  430 + with Image.open(io.BytesIO(file_content)) as img:
  431 + # Convert to RGB to handle PNG/RGBA correctly
  432 + if img.mode in ("RGBA", "P"):
  433 + img = img.convert("RGB")
  434 +
  435 + # Resize while maintaining aspect ratio, max 400px
  436 + img.thumbnail((400, 400))
  437 +
  438 + # Save optimized JPEG
  439 + img.save(thumbnail_path, "JPEG", quality=70, optimize=True)
  440 + logger.info(f"Saved optimized thumbnail to {thumbnail_path}")
  441 +
  442 + except Exception as e:
  443 + logger.error(f"Failed to generate thumbnail: {e}")
  444 + # Fallback: try to write original file if PIL fails, but rename extension if needed
  445 + try:
  446 + with open(thumbnail_path, "wb") as f:
  447 + f.write(file_content)
  448 + except Exception as e2:
  449 + logger.error(f"Failed to save fallback thumbnail: {e2}")
  450 + thumbnail_url_path = None
  451 +
405 files = { 452 files = {
406 'image': (image.filename, file_content, image.content_type) 453 'image': (image.filename, file_content, image.content_type)
407 } 454 }
@@ -425,6 +472,13 @@ async def submit_video_job_proxy( @@ -425,6 +472,13 @@ async def submit_video_job_proxy(
425 472
426 result = resp.json() 473 result = resp.json()
427 474
  475 + # Inject thumbnail URL into the response so frontend can use it
  476 + if thumbnail_url_path:
  477 + logger.info(f"Injecting thumbnail URL: {thumbnail_url_path}")
  478 + result["thumbnail"] = thumbnail_url_path
  479 + else:
  480 + logger.warning("Thumbnail URL path is None, skipping injection")
  481 +
428 # 3. Increment Usage (Only if successful) 482 # 3. Increment Usage (Only if successful)
429 if author_id != ADMIN_ID: 483 if author_id != ADMIN_ID:
430 usage_store.increment_used(author_id) 484 usage_store.increment_used(author_id)
@@ -474,6 +528,10 @@ async def gallery_videos(limit: int = Query(200, ge=1, le=1000), author_id: Opti @@ -474,6 +528,10 @@ async def gallery_videos(limit: int = Query(200, ge=1, le=1000), author_id: Opti
474 @app.post("/gallery/videos") 528 @app.post("/gallery/videos")
475 async def add_video(video: GalleryVideo): 529 async def add_video(video: GalleryVideo):
476 try: 530 try:
  531 + if video.thumbnail:
  532 + logger.info(f"Saving video {video.id} with thumbnail: {video.thumbnail}")
  533 + else:
  534 + logger.warning(f"Saving video {video.id} WITHOUT thumbnail")
477 return video_store.add_item(video) 535 return video_store.add_item(video)
478 except Exception as exc: 536 except Exception as exc:
479 raise HTTPException(status_code=500, detail=f"Failed to store video metadata: {exc}") 537 raise HTTPException(status_code=500, detail=f"Failed to store video metadata: {exc}")
@@ -21,6 +21,8 @@ LOGS_DIR="$BASE_DIR/logs" @@ -21,6 +21,8 @@ LOGS_DIR="$BASE_DIR/logs"
21 21
22 # Ensure logs directory exists 22 # Ensure logs directory exists
23 mkdir -p "$LOGS_DIR" 23 mkdir -p "$LOGS_DIR"
  24 +# Ensure frontend dist exists or create it
  25 +mkdir -p "$FRONTEND_DIR/dist"
24 26
25 echo "==================================================" 27 echo "=================================================="
26 echo "Initializing Front-Backend Z-Image Services (PM2)" 28 echo "Initializing Front-Backend Z-Image Services (PM2)"
@@ -40,7 +42,9 @@ window.APP_CONFIG = { @@ -40,7 +42,9 @@ window.APP_CONFIG = {
40 LIKES_FOR_REWARD: ${LIKES_FOR_REWARD:-5} 42 LIKES_FOR_REWARD: ${LIKES_FOR_REWARD:-5}
41 }; 43 };
42 EOF 44 EOF
43 -cp "$CONFIG_JS_FILE" "$FRONTEND_DIR/dist/config.js" 2>/dev/null || true 45 +# Copy config to frontend dist to ensure it is served
  46 +cp "$CONFIG_JS_FILE" "$FRONTEND_DIR/dist/config.js"
  47 +
44 export TURBO_DIFFUSION_LOCAL_URL="http://127.0.0.1:$LOCAL_TURBO_PORT" 48 export TURBO_DIFFUSION_LOCAL_URL="http://127.0.0.1:$LOCAL_TURBO_PORT"
45 export VITE_API_BASE_URL="http://$PUBLIC_IP:$PUBLIC_BACKEND_PORT" 49 export VITE_API_BASE_URL="http://$PUBLIC_IP:$PUBLIC_BACKEND_PORT"
46 export WHITELIST_PATH="$BASE_DIR/backend/whitelist.txt" 50 export WHITELIST_PATH="$BASE_DIR/backend/whitelist.txt"
@@ -132,7 +132,7 @@ const App: React.FC = () => { @@ -132,7 +132,7 @@ const App: React.FC = () => {
132 setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); 132 setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });
133 133
134 try { 134 try {
135 - const taskId = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId, params.seed); 135 + const { taskId, thumbnail } = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId, params.seed);
136 const finalStatus = await pollVideoStatus(taskId, setVideoStatus); 136 const finalStatus = await pollVideoStatus(taskId, setVideoStatus);
137 137
138 if (!finalStatus.video_filename) { 138 if (!finalStatus.video_filename) {
@@ -142,6 +142,7 @@ const App: React.FC = () => { @@ -142,6 +142,7 @@ const App: React.FC = () => {
142 const newVideoData: ImageItem = { 142 const newVideoData: ImageItem = {
143 id: `vid-${Date.now()}`, 143 id: `vid-${Date.now()}`,
144 url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`, 144 url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`,
  145 + thumbnail: thumbnail, // Save the thumbnail URL
145 prompt: params.prompt, 146 prompt: params.prompt,
146 authorId: currentUser.employeeId, 147 authorId: currentUser.employeeId,
147 createdAt: Date.now(), 148 createdAt: Date.now(),
@@ -39,6 +39,10 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -39,6 +39,10 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
39 } 39 }
40 } 40 }
41 41
  42 + // If it's a video with a thumbnail, we want to show it immediately (via poster)
  43 + // instead of waiting for the video file to load metadata.
  44 + const showContent = isLoaded || (isVideo && !!image.thumbnail);
  45 +
42 return ( 46 return (
43 <div 47 <div
44 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" 48 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"
@@ -47,7 +51,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -47,7 +51,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
47 onMouseLeave={handleMouseLeave} 51 onMouseLeave={handleMouseLeave}
48 > 52 >
49 {/* Placeholder / Skeleton */} 53 {/* Placeholder / Skeleton */}
50 - {!isLoaded && ( 54 + {!showContent && (
51 <div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" /> 55 <div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" />
52 )} 56 )}
53 57
@@ -56,7 +60,8 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -56,7 +60,8 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
56 <video 60 <video
57 ref={videoRef} 61 ref={videoRef}
58 src={image.url} 62 src={image.url}
59 - className={`w-full h-auto object-cover transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`} 63 + poster={image.thumbnail} // Use thumbnail as poster
  64 + className={`w-full h-auto object-cover transition-opacity duration-300 ${showContent ? 'opacity-100' : 'opacity-0'}`}
60 onLoadedMetadata={handleVideoMetadata} 65 onLoadedMetadata={handleVideoMetadata}
61 onLoadedData={() => setIsLoaded(true)} 66 onLoadedData={() => setIsLoaded(true)}
62 loop 67 loop
@@ -68,7 +73,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs @@ -68,7 +73,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
68 <img 73 <img
69 src={image.url} 74 src={image.url}
70 alt={image.prompt} 75 alt={image.prompt}
71 - className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${isLoaded ? 'opacity-100' : 'opacity-0'}`} 76 + className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${showContent ? 'opacity-100' : 'opacity-0'}`}
72 onLoad={() => setIsLoaded(true)} 77 onLoad={() => setIsLoaded(true)}
73 loading="lazy" 78 loading="lazy"
74 /> 79 />
@@ -51,9 +51,9 @@ const compressImage = async (file: File): Promise<File> => { @@ -51,9 +51,9 @@ const compressImage = async (file: File): Promise<File> => {
51 51
52 /** 52 /**
53 * Submits a video generation job to the backend. 53 * Submits a video generation job to the backend.
54 - * @returns The task ID for the submitted job. 54 + * @returns The task ID and thumbnail URL.
55 */ 55 */
56 -export const submitVideoJob = async (prompt: string, image: File, authorId: string, seed: number): Promise<string> => { 56 +export const submitVideoJob = async (prompt: string, image: File, authorId: string, seed: number): Promise<{taskId: string, thumbnail?: string}> => {
57 let finalImage = image; 57 let finalImage = image;
58 if (image.size > 1024 * 1024) { 58 if (image.size > 1024 * 1024) {
59 finalImage = await compressImage(image); 59 finalImage = await compressImage(image);
@@ -85,8 +85,8 @@ export const submitVideoJob = async (prompt: string, image: File, authorId: stri @@ -85,8 +85,8 @@ export const submitVideoJob = async (prompt: string, image: File, authorId: stri
85 throw new Error(`Job submission failed: ${errorText}`); 85 throw new Error(`Job submission failed: ${errorText}`);
86 } 86 }
87 87
88 - const { task_id } = await submitRes.json();  
89 - return task_id; 88 + const { task_id, thumbnail } = await submitRes.json();
  89 + return { taskId: task_id, thumbnail };
90 }; 90 };
91 91
92 /** 92 /**
@@ -13,6 +13,7 @@ export interface ImageGenerationParams { @@ -13,6 +13,7 @@ export interface ImageGenerationParams {
13 export interface ImageItem extends ImageGenerationParams { 13 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 + thumbnail?: string; // Thumbnail URL for videos
16 createdAt: number; 17 createdAt: number;
17 generationTime?: number; // Time in seconds for generation 18 generationTime?: number; // Time in seconds for generation
18 19