Showing
6 changed files
with
78 additions
and
9 deletions
| @@ -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 |
-
Please register or login to post a comment