ly0303521

视频加载使用缩略图

... ... @@ -14,6 +14,8 @@ from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ConfigDict
import logging
from PIL import Image
import io
# --- Constants ---
logger = logging.getLogger("uvicorn.error")
... ... @@ -175,6 +177,7 @@ class GalleryVideo(GalleryItem):
seed: Optional[int] = Field(default=None, ge=0)
width: int = Field(1024, ge=64, le=2048)
height: int = Field(1024, ge=64, le=2048)
thumbnail: Optional[str] = None
class ImageGenerationResponse(BaseModel):
image: Optional[str] = None
... ... @@ -310,6 +313,10 @@ usage_store = UsageStore(USAGE_PATH)
# --- App Setup ---
app = FastAPI(title="Z-Image Proxy", version="1.0.0")
from fastapi.staticfiles import StaticFiles
# Mount public directory to serve thumbnails and frontend config
app.mount("/thumbnails", StaticFiles(directory=str(Path(__file__).parent.parent / "public" / "thumbnails")), name="thumbnails")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
... ... @@ -402,6 +409,46 @@ async def submit_video_job_proxy(
# Prepare files and data for forwarding
file_content = await image.read()
# --- Save Thumbnail ---
# Generate a lightweight thumbnail (max 400px width/height, ~20KB)
thumbnail_filename = f"thumb_{secrets.token_hex(8)}.jpg" # Always use jpg for efficiency
# Updated Path: Save to OSS directory
OSS_THUMBNAIL_DIR = Path("/home/inspur/work_space/gen_img_video/TurboDiffusion-Space/ASSERT/艺云-DESIGN/thumbnails")
thumbnail_path = OSS_THUMBNAIL_DIR / thumbnail_filename
# Updated URL: Construct from environment variables (PUBLIC_IP and PUBLIC_OSS_PORT)
public_ip = os.getenv("PUBLIC_IP", "106.120.52.146")
oss_port = os.getenv("PUBLIC_OSS_PORT", "34000")
oss_base_url = f"http://{public_ip}:{oss_port}"
thumbnail_url_path = f"{oss_base_url}/thumbnails/{thumbnail_filename}"
try:
thumbnail_path.parent.mkdir(parents=True, exist_ok=True)
# Use PIL to resize and compress
with Image.open(io.BytesIO(file_content)) as img:
# Convert to RGB to handle PNG/RGBA correctly
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Resize while maintaining aspect ratio, max 400px
img.thumbnail((400, 400))
# Save optimized JPEG
img.save(thumbnail_path, "JPEG", quality=70, optimize=True)
logger.info(f"Saved optimized thumbnail to {thumbnail_path}")
except Exception as e:
logger.error(f"Failed to generate thumbnail: {e}")
# Fallback: try to write original file if PIL fails, but rename extension if needed
try:
with open(thumbnail_path, "wb") as f:
f.write(file_content)
except Exception as e2:
logger.error(f"Failed to save fallback thumbnail: {e2}")
thumbnail_url_path = None
files = {
'image': (image.filename, file_content, image.content_type)
}
... ... @@ -425,6 +472,13 @@ async def submit_video_job_proxy(
result = resp.json()
# Inject thumbnail URL into the response so frontend can use it
if thumbnail_url_path:
logger.info(f"Injecting thumbnail URL: {thumbnail_url_path}")
result["thumbnail"] = thumbnail_url_path
else:
logger.warning("Thumbnail URL path is None, skipping injection")
# 3. Increment Usage (Only if successful)
if author_id != ADMIN_ID:
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
@app.post("/gallery/videos")
async def add_video(video: GalleryVideo):
try:
if video.thumbnail:
logger.info(f"Saving video {video.id} with thumbnail: {video.thumbnail}")
else:
logger.warning(f"Saving video {video.id} WITHOUT thumbnail")
return video_store.add_item(video)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to store video metadata: {exc}")
... ...
... ... @@ -21,6 +21,8 @@ LOGS_DIR="$BASE_DIR/logs"
# Ensure logs directory exists
mkdir -p "$LOGS_DIR"
# Ensure frontend dist exists or create it
mkdir -p "$FRONTEND_DIR/dist"
echo "=================================================="
echo "Initializing Front-Backend Z-Image Services (PM2)"
... ... @@ -40,7 +42,9 @@ window.APP_CONFIG = {
LIKES_FOR_REWARD: ${LIKES_FOR_REWARD:-5}
};
EOF
cp "$CONFIG_JS_FILE" "$FRONTEND_DIR/dist/config.js" 2>/dev/null || true
# Copy config to frontend dist to ensure it is served
cp "$CONFIG_JS_FILE" "$FRONTEND_DIR/dist/config.js"
export TURBO_DIFFUSION_LOCAL_URL="http://127.0.0.1:$LOCAL_TURBO_PORT"
export VITE_API_BASE_URL="http://$PUBLIC_IP:$PUBLIC_BACKEND_PORT"
export WHITELIST_PATH="$BASE_DIR/backend/whitelist.txt"
... ...
... ... @@ -132,7 +132,7 @@ const App: React.FC = () => {
setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' });
try {
const taskId = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId, params.seed);
const { taskId, thumbnail } = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId, params.seed);
const finalStatus = await pollVideoStatus(taskId, setVideoStatus);
if (!finalStatus.video_filename) {
... ... @@ -142,6 +142,7 @@ const App: React.FC = () => {
const newVideoData: ImageItem = {
id: `vid-${Date.now()}`,
url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`,
thumbnail: thumbnail, // Save the thumbnail URL
prompt: params.prompt,
authorId: currentUser.employeeId,
createdAt: Date.now(),
... ...
... ... @@ -39,6 +39,10 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
}
}
// If it's a video with a thumbnail, we want to show it immediately (via poster)
// instead of waiting for the video file to load metadata.
const showContent = isLoaded || (isVideo && !!image.thumbnail);
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"
... ... @@ -47,7 +51,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
onMouseLeave={handleMouseLeave}
>
{/* Placeholder / Skeleton */}
{!isLoaded && (
{!showContent && (
<div className="absolute inset-0 bg-gray-200 animate-pulse min-h-[200px]" />
)}
... ... @@ -56,7 +60,8 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
<video
ref={videoRef}
src={image.url}
className={`w-full h-auto object-cover transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
poster={image.thumbnail} // Use thumbnail as poster
className={`w-full h-auto object-cover transition-opacity duration-300 ${showContent ? 'opacity-100' : 'opacity-0'}`}
onLoadedMetadata={handleVideoMetadata}
onLoadedData={() => setIsLoaded(true)}
loop
... ... @@ -68,7 +73,7 @@ const ImageCard: React.FC<ImageCardProps> = ({ image, onClick, onLike, currentUs
<img
src={image.url}
alt={image.prompt}
className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
className={`w-full h-auto object-cover transition-transform duration-700 ease-in-out group-hover:scale-105 ${showContent ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setIsLoaded(true)}
loading="lazy"
/>
... ...
... ... @@ -51,9 +51,9 @@ const compressImage = async (file: File): Promise<File> => {
/**
* Submits a video generation job to the backend.
* @returns The task ID for the submitted job.
* @returns The task ID and thumbnail URL.
*/
export const submitVideoJob = async (prompt: string, image: File, authorId: string, seed: number): Promise<string> => {
export const submitVideoJob = async (prompt: string, image: File, authorId: string, seed: number): Promise<{taskId: string, thumbnail?: string}> => {
let finalImage = image;
if (image.size > 1024 * 1024) {
finalImage = await compressImage(image);
... ... @@ -85,8 +85,8 @@ export const submitVideoJob = async (prompt: string, image: File, authorId: stri
throw new Error(`Job submission failed: ${errorText}`);
}
const { task_id } = await submitRes.json();
return task_id;
const { task_id, thumbnail } = await submitRes.json();
return { taskId: task_id, thumbnail };
};
/**
... ...
... ... @@ -13,6 +13,7 @@ export interface ImageGenerationParams {
export interface ImageItem extends ImageGenerationParams {
id: string;
url: string; // base64 data URI or http URL
thumbnail?: string; // Thumbnail URL for videos
createdAt: number;
generationTime?: number; // Time in seconds for generation
... ...