Showing
7 changed files
with
297 additions
and
470 deletions
| @@ -13,19 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware | @@ -13,19 +13,20 @@ from fastapi.middleware.cors import CORSMiddleware | ||
| 13 | from pydantic import BaseModel, Field, ConfigDict | 13 | from pydantic import BaseModel, Field, ConfigDict |
| 14 | import logging | 14 | import logging |
| 15 | 15 | ||
| 16 | +# --- Constants --- | ||
| 16 | logger = logging.getLogger("uvicorn.error") | 17 | logger = logging.getLogger("uvicorn.error") |
| 17 | -logging.basicConfig(level=logging.INFO) | ||
| 18 | -logger.info("your message %s", "hello") | ||
| 19 | Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009").rstrip("/") | 18 | Z_IMAGE_BASE_URL = os.getenv("Z_IMAGE_BASE_URL", "http://106.120.52.146:39009").rstrip("/") |
| 20 | REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120")) | 19 | REQUEST_TIMEOUT_SECONDS = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "120")) |
| 21 | -GALLERY_DATA_PATH = Path(os.getenv("GALLERY_DATA_PATH", Path(__file__).with_name("gallery_data.json"))) | 20 | +GALLERY_IMAGES_PATH = Path(os.getenv("GALLERY_IMAGES_PATH", Path(__file__).with_name("gallery_images.json"))) |
| 21 | +GALLERY_VIDEOS_PATH = Path(os.getenv("GALLERY_VIDEOS_PATH", Path(__file__).with_name("gallery_videos.json"))) | ||
| 22 | GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500")) | 22 | GALLERY_MAX_ITEMS = int(os.getenv("GALLERY_MAX_ITEMS", "500")) |
| 23 | WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt"))) | 23 | WHITELIST_PATH = Path(os.getenv("WHITELIST_PATH", Path(__file__).with_name("whitelist.txt"))) |
| 24 | 24 | ||
| 25 | +# --- Pydantic Models --- | ||
| 26 | +# Define dependent models first to avoid forward reference issues. | ||
| 25 | 27 | ||
| 26 | class ImageGenerationPayload(BaseModel): | 28 | class ImageGenerationPayload(BaseModel): |
| 27 | model_config = ConfigDict(populate_by_name=True) | 29 | model_config = ConfigDict(populate_by_name=True) |
| 28 | - | ||
| 29 | prompt: str = Field(..., min_length=1, max_length=2048) | 30 | prompt: str = Field(..., min_length=1, max_length=2048) |
| 30 | height: int = Field(1024, ge=64, le=2048) | 31 | height: int = Field(1024, ge=64, le=2048) |
| 31 | width: int = Field(1024, ge=64, le=2048) | 32 | width: int = Field(1024, ge=64, le=2048) |
| @@ -36,309 +37,210 @@ class ImageGenerationPayload(BaseModel): | @@ -36,309 +37,210 @@ class ImageGenerationPayload(BaseModel): | ||
| 36 | output_format: Literal["base64", "url"] = "base64" | 37 | output_format: Literal["base64", "url"] = "base64" |
| 37 | author_id: Optional[str] = Field(default=None, alias="authorId", min_length=1, max_length=64) | 38 | author_id: Optional[str] = Field(default=None, alias="authorId", min_length=1, max_length=64) |
| 38 | 39 | ||
| 39 | - | ||
| 40 | -class ImageGenerationResponse(BaseModel): | ||
| 41 | - image: Optional[str] = None | ||
| 42 | - url: Optional[str] = None | ||
| 43 | - time_taken: float = 0.0 | ||
| 44 | - error: Optional[str] = None | ||
| 45 | - request_params: ImageGenerationPayload | ||
| 46 | - gallery_item: Optional["GalleryImage"] = None | ||
| 47 | - | ||
| 48 | - | ||
| 49 | -class GalleryImage(BaseModel): | 40 | +class GalleryItem(BaseModel): |
| 50 | model_config = ConfigDict(populate_by_name=True) | 41 | model_config = ConfigDict(populate_by_name=True) |
| 51 | - | ||
| 52 | id: str | 42 | id: str |
| 53 | prompt: str = Field(..., min_length=1, max_length=2048) | 43 | prompt: str = Field(..., min_length=1, max_length=2048) |
| 54 | - height: int = Field(..., ge=64, le=2048) | ||
| 55 | - width: int = Field(..., ge=64, le=2048) | ||
| 56 | - num_inference_steps: int = Field(..., ge=1, le=200) | ||
| 57 | - guidance_scale: float = Field(..., ge=0.0, le=20.0) | ||
| 58 | - seed: int = Field(..., ge=0) | ||
| 59 | url: str | 44 | url: str |
| 60 | created_at: float = Field(default_factory=lambda: time.time() * 1000, alias="createdAt") | 45 | created_at: float = Field(default_factory=lambda: time.time() * 1000, alias="createdAt") |
| 61 | author_id: Optional[str] = Field(default=None, alias="authorId") | 46 | author_id: Optional[str] = Field(default=None, alias="authorId") |
| 62 | likes: int = 0 | 47 | likes: int = 0 |
| 63 | is_mock: bool = Field(default=False, alias="isMock") | 48 | is_mock: bool = Field(default=False, alias="isMock") |
| 64 | - negative_prompt: Optional[str] = None | ||
| 65 | liked_by: List[str] = Field(default_factory=list, alias="likedBy") | 49 | liked_by: List[str] = Field(default_factory=list, alias="likedBy") |
| 66 | 50 | ||
| 51 | +class GalleryImage(GalleryItem): | ||
| 52 | + height: int = Field(..., ge=64, le=2048) | ||
| 53 | + width: int = Field(..., ge=64, le=2048) | ||
| 54 | + num_inference_steps: int = Field(..., ge=1, le=200) | ||
| 55 | + guidance_scale: float = Field(..., ge=0.0, le=20.0) | ||
| 56 | + seed: int = Field(..., ge=0) | ||
| 57 | + negative_prompt: Optional[str] = None | ||
| 67 | 58 | ||
| 68 | -ImageGenerationResponse.model_rebuild() | 59 | +class GalleryVideo(GalleryItem): |
| 60 | + generation_time: Optional[float] = Field(default=None, alias="generationTime") | ||
| 69 | 61 | ||
| 62 | +class ImageGenerationResponse(BaseModel): | ||
| 63 | + image: Optional[str] = None | ||
| 64 | + url: Optional[str] = None | ||
| 65 | + time_taken: float = 0.0 | ||
| 66 | + error: Optional[str] = None | ||
| 67 | + request_params: ImageGenerationPayload | ||
| 68 | + gallery_item: Optional[GalleryImage] = None # No forward ref needed now | ||
| 70 | 69 | ||
| 71 | -class WhitelistStore: | ||
| 72 | - """Simple text file backed whitelist.""" | 70 | +# --- Data Stores --- |
| 73 | 71 | ||
| 72 | +class WhitelistStore: | ||
| 74 | def __init__(self, path: Path) -> None: | 73 | def __init__(self, path: Path) -> None: |
| 75 | self.path = path | 74 | self.path = path |
| 76 | self.lock = RLock() | 75 | self.lock = RLock() |
| 77 | - if not self.path.exists(): | ||
| 78 | - # Default admin ID if file doesn't exist | ||
| 79 | - self._write(["86427531"]) | ||
| 80 | - | 76 | + if not self.path.exists(): self._write(["86427531"]) |
| 81 | def _read(self) -> List[str]: | 77 | def _read(self) -> List[str]: |
| 82 | - if not self.path.exists(): | ||
| 83 | - return [] | 78 | + if not self.path.exists(): return [] |
| 84 | try: | 79 | try: |
| 85 | with self.path.open("r", encoding="utf-8") as f: | 80 | with self.path.open("r", encoding="utf-8") as f: |
| 86 | - lines = f.read().splitlines() | ||
| 87 | - return [line.strip() for line in lines if line.strip()] | ||
| 88 | - except OSError: | ||
| 89 | - return [] | ||
| 90 | - | 81 | + return [line.strip() for line in f.read().splitlines() if line.strip()] |
| 82 | + except OSError: return [] | ||
| 91 | def _write(self, ids: List[str]) -> None: | 83 | def _write(self, ids: List[str]) -> None: |
| 92 | with self.lock: | 84 | with self.lock: |
| 93 | try: | 85 | try: |
| 94 | - with self.path.open("w", encoding="utf-8") as f: | ||
| 95 | - f.write("\n".join(ids)) | ||
| 96 | - except OSError as exc: | ||
| 97 | - print(f"[WARN] Failed to write whitelist: {exc}") | ||
| 98 | - | ||
| 99 | - def is_allowed(self, user_id: str) -> bool: | ||
| 100 | - allowed = self._read() | ||
| 101 | - return user_id in allowed | ||
| 102 | - | 86 | + with self.path.open("w", encoding="utf-8") as f: f.write("\n".join(ids)) |
| 87 | + except OSError as exc: print(f"[WARN] Failed to write whitelist: {exc}") | ||
| 88 | + def is_allowed(self, user_id: str) -> bool: return user_id in self._read() | ||
| 103 | def add_users(self, user_ids: List[str]) -> None: | 89 | def add_users(self, user_ids: List[str]) -> None: |
| 104 | with self.lock: | 90 | with self.lock: |
| 105 | - current = set(self._read()) | ||
| 106 | - current.update(user_ids) | ||
| 107 | - self._write(sorted(list(current))) | ||
| 108 | - | 91 | + current = set(self._read()); current.update(user_ids); self._write(sorted(list(current))) |
| 109 | def remove_user(self, user_id: str) -> None: | 92 | def remove_user(self, user_id: str) -> None: |
| 110 | with self.lock: | 93 | with self.lock: |
| 111 | current = self._read() | 94 | current = self._read() |
| 112 | - if user_id in current: | ||
| 113 | - current = [uid for uid in current if uid != user_id] | ||
| 114 | - self._write(current) | ||
| 115 | - | ||
| 116 | - def get_all(self) -> List[str]: | ||
| 117 | - return self._read() | 95 | + if user_id in current: self._write([uid for uid in current if uid != user_id]) |
| 96 | + def get_all(self) -> List[str]: return self._read() | ||
| 118 | 97 | ||
| 119 | - | ||
| 120 | -class GalleryStore: | ||
| 121 | - """Simple JSON file backed store for generated images.""" | ||
| 122 | - | ||
| 123 | - def __init__(self, path: Path, max_items: int = 500) -> None: | 98 | +class JsonStore: |
| 99 | + """Generic JSON file backed store for a list of items.""" | ||
| 100 | + def __init__(self, path: Path, item_key: str, max_items: int = 500) -> None: | ||
| 124 | self.path = path | 101 | self.path = path |
| 102 | + self.item_key = item_key | ||
| 125 | self.max_items = max_items | 103 | self.max_items = max_items |
| 126 | self.lock = Lock() | 104 | self.lock = Lock() |
| 127 | - self.enabled = True | ||
| 128 | - self._memory_cache: List[dict] = [] | ||
| 129 | try: | 105 | try: |
| 130 | self.path.parent.mkdir(parents=True, exist_ok=True) | 106 | self.path.parent.mkdir(parents=True, exist_ok=True) |
| 131 | - if self.path.exists(): | ||
| 132 | - self._memory_cache = self._read().get("images", []) | ||
| 133 | - else: | ||
| 134 | - self._write({"images": []}) | ||
| 135 | - except OSError as exc: # pragma: no cover - filesystem guards | ||
| 136 | - self.enabled = False | ||
| 137 | - print(f"[WARN] Gallery store disabled due to filesystem error: {exc}") | ||
| 138 | - | 107 | + if not self.path.exists(): self._write({self.item_key: []}) |
| 108 | + except OSError as exc: print(f"[WARN] JSON store at {path} disabled: {exc}") | ||
| 139 | def _read(self) -> dict: | 109 | def _read(self) -> dict: |
| 140 | - if not self.enabled: | ||
| 141 | - return {"images": list(self._memory_cache)} | ||
| 142 | try: | 110 | try: |
| 143 | - with self.path.open("r", encoding="utf-8") as file: | ||
| 144 | - return json.load(file) | ||
| 145 | - except (FileNotFoundError, json.JSONDecodeError): | ||
| 146 | - return {"images": []} | ||
| 147 | - | 111 | + with self.path.open("r", encoding="utf-8") as file: return json.load(file) |
| 112 | + except (FileNotFoundError, json.JSONDecodeError): return {self.item_key: []} | ||
| 148 | def _write(self, data: dict) -> None: | 113 | def _write(self, data: dict) -> None: |
| 149 | - if not self.enabled: | ||
| 150 | - self._memory_cache = list(data.get("images", [])) | ||
| 151 | - return | ||
| 152 | payload = json.dumps(data, ensure_ascii=False, indent=2) | 114 | payload = json.dumps(data, ensure_ascii=False, indent=2) |
| 153 | temp_path = self.path.with_suffix(".tmp") | 115 | temp_path = self.path.with_suffix(".tmp") |
| 154 | try: | 116 | try: |
| 155 | - with temp_path.open("w", encoding="utf-8") as file: | ||
| 156 | - file.write(payload) | 117 | + with temp_path.open("w", encoding="utf-8") as file: file.write(payload) |
| 157 | temp_path.replace(self.path) | 118 | temp_path.replace(self.path) |
| 158 | - except OSError as exc: | ||
| 159 | - # Some filesystems (or permissions) may block atomic replace; fall back to direct write | ||
| 160 | - print(f"[WARN] Atomic gallery write failed, attempting direct write: {exc}") | ||
| 161 | - try: | ||
| 162 | - with self.path.open("w", encoding="utf-8") as file: | ||
| 163 | - file.write(payload) | ||
| 164 | - except OSError as direct_exc: | ||
| 165 | - raise direct_exc | ||
| 166 | - self._memory_cache = list(data.get("images", [])) | ||
| 167 | - | ||
| 168 | - def list_images(self) -> List[dict]: | ||
| 169 | - with self.lock: | ||
| 170 | - data = self._read() | ||
| 171 | - return list(data.get("images", [])) | ||
| 172 | - | ||
| 173 | - def add_image(self, image: GalleryImage) -> dict: | ||
| 174 | - payload = image.model_dump(by_alias=True) | 119 | + except OSError: |
| 120 | + with self.path.open("w", encoding="utf-8") as file: file.write(payload) | ||
| 121 | + def list_items(self) -> List[dict]: | ||
| 122 | + with self.lock: return self._read().get(self.item_key, []) | ||
| 123 | + def add_item(self, item: BaseModel) -> dict: | ||
| 124 | + payload = item.model_dump(by_alias=True) | ||
| 175 | with self.lock: | 125 | with self.lock: |
| 176 | data = self._read() | 126 | data = self._read() |
| 177 | - images = data.get("images", []) | ||
| 178 | - images.insert(0, payload) | ||
| 179 | - data["images"] = images[: self.max_items] | 127 | + items = data.get(self.item_key, []) |
| 128 | + items.insert(0, payload) | ||
| 129 | + data[self.item_key] = items[:self.max_items] | ||
| 180 | self._write(data) | 130 | self._write(data) |
| 181 | return payload | 131 | return payload |
| 182 | - | ||
| 183 | - def toggle_like(self, image_id: str, user_id: str) -> Optional[dict]: | 132 | + def toggle_like(self, item_id: str, user_id: str) -> Optional[dict]: |
| 184 | with self.lock: | 133 | with self.lock: |
| 185 | data = self._read() | 134 | data = self._read() |
| 186 | - images = data.get("images", []) | ||
| 187 | - target_image = next((img for img in images if img.get("id") == image_id), None) | ||
| 188 | - | ||
| 189 | - if not target_image: | ||
| 190 | - return None | ||
| 191 | - | ||
| 192 | - liked_by = target_image.get("likedBy", []) | ||
| 193 | - # Handle legacy data where likedBy might be missing | ||
| 194 | - if not isinstance(liked_by, list): | ||
| 195 | - liked_by = [] | ||
| 196 | - | 135 | + items = data.get(self.item_key, []) |
| 136 | + target_item = next((i for i in items if i.get("id") == item_id), None) | ||
| 137 | + if not target_item: return None | ||
| 138 | + liked_by = target_item.get("likedBy", []) | ||
| 139 | + if not isinstance(liked_by, list): liked_by = [] | ||
| 197 | if user_id in liked_by: | 140 | if user_id in liked_by: |
| 198 | liked_by.remove(user_id) | 141 | liked_by.remove(user_id) |
| 199 | - target_image["likes"] = max(0, target_image.get("likes", 0) - 1) | 142 | + target_item["likes"] = max(0, target_item.get("likes", 0) - 1) |
| 200 | else: | 143 | else: |
| 201 | liked_by.append(user_id) | 144 | liked_by.append(user_id) |
| 202 | - target_image["likes"] = target_image.get("likes", 0) + 1 | ||
| 203 | - | ||
| 204 | - target_image["likedBy"] = liked_by | 145 | + target_item["likes"] = target_item.get("likes", 0) + 1 |
| 146 | + target_item["likedBy"] = liked_by | ||
| 205 | self._write(data) | 147 | self._write(data) |
| 206 | - return target_image | ||
| 207 | - | 148 | + return target_item |
| 208 | 149 | ||
| 209 | -gallery_store = GalleryStore(GALLERY_DATA_PATH, GALLERY_MAX_ITEMS) | 150 | +image_store = JsonStore(GALLERY_IMAGES_PATH, item_key="images", max_items=GALLERY_MAX_ITEMS) |
| 151 | +video_store = JsonStore(GALLERY_VIDEOS_PATH, item_key="videos", max_items=GALLERY_MAX_ITEMS) | ||
| 210 | whitelist_store = WhitelistStore(WHITELIST_PATH) | 152 | whitelist_store = WhitelistStore(WHITELIST_PATH) |
| 211 | 153 | ||
| 212 | - | 154 | +# --- App Setup --- |
| 213 | app = FastAPI(title="Z-Image Proxy", version="1.0.0") | 155 | app = FastAPI(title="Z-Image Proxy", version="1.0.0") |
| 214 | - | ||
| 215 | app.add_middleware( | 156 | app.add_middleware( |
| 216 | CORSMiddleware, | 157 | CORSMiddleware, |
| 217 | - allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","), | 158 | + allow_origins=["http://106.120.52.146:37001"], # Explicitly allow the frontend origin |
| 218 | allow_credentials=True, | 159 | allow_credentials=True, |
| 219 | allow_methods=["*"], | 160 | allow_methods=["*"], |
| 220 | allow_headers=["*"], | 161 | allow_headers=["*"], |
| 221 | ) | 162 | ) |
| 222 | - | ||
| 223 | - | ||
| 224 | @app.on_event("startup") | 163 | @app.on_event("startup") |
| 225 | -async def startup() -> None: | ||
| 226 | - timeout = httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0) | ||
| 227 | - app.state.http = httpx.AsyncClient(timeout=timeout) | ||
| 228 | - | ||
| 229 | - | 164 | +async def startup(): app.state.http = httpx.AsyncClient(timeout=httpx.Timeout(REQUEST_TIMEOUT_SECONDS, connect=5.0)) |
| 230 | @app.on_event("shutdown") | 165 | @app.on_event("shutdown") |
| 231 | -async def shutdown() -> None: | ||
| 232 | - await app.state.http.aclose() | ||
| 233 | - | ||
| 234 | - | 166 | +async def shutdown(): await app.state.http.aclose() |
| 235 | @app.get("/health") | 167 | @app.get("/health") |
| 236 | -async def health() -> dict: | ||
| 237 | - return {"status": "ok"} | ||
| 238 | - | 168 | +async def health(): return {"status": "ok"} |
| 239 | 169 | ||
| 170 | +# --- Endpoints --- | ||
| 240 | @app.post("/auth/login") | 171 | @app.post("/auth/login") |
| 241 | -async def login(user_id: str = Query(..., alias="userId")) -> dict: | ||
| 242 | - if whitelist_store.is_allowed(user_id): | ||
| 243 | - return {"status": "ok", "userId": user_id} | 172 | +async def login(user_id: str = Query(..., alias="userId")): |
| 173 | + if whitelist_store.is_allowed(user_id): return {"status": "ok", "userId": user_id} | ||
| 244 | raise HTTPException(status_code=403, detail="User not whitelisted") | 174 | raise HTTPException(status_code=403, detail="User not whitelisted") |
| 245 | 175 | ||
| 176 | +@app.post("/likes/{item_id}") | ||
| 177 | +async def toggle_like(item_id: str, user_id: str = Query(..., alias="userId")): | ||
| 178 | + updated_item = image_store.toggle_like(item_id, user_id) | ||
| 179 | + if updated_item: return updated_item | ||
| 180 | + updated_item = video_store.toggle_like(item_id, user_id) | ||
| 181 | + if updated_item: return updated_item | ||
| 182 | + raise HTTPException(status_code=404, detail="Item not found") | ||
| 183 | + | ||
| 184 | +@app.get("/gallery/images") | ||
| 185 | +async def gallery_images(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")): | ||
| 186 | + items = image_store.list_items() | ||
| 187 | + if author_id: items = [item for item in items if item.get("authorId") == author_id] | ||
| 188 | + return {"images": items[:limit]} | ||
| 189 | + | ||
| 190 | +@app.get("/gallery/videos") | ||
| 191 | +async def gallery_videos(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")): | ||
| 192 | + items = video_store.list_items() | ||
| 193 | + if author_id: items = [item for item in items if item.get("authorId") == author_id] | ||
| 194 | + return {"videos": items[:limit]} | ||
| 195 | + | ||
| 196 | +@app.post("/gallery/videos") | ||
| 197 | +async def add_video(video: GalleryVideo): | ||
| 198 | + try: | ||
| 199 | + return video_store.add_item(video) | ||
| 200 | + except Exception as exc: | ||
| 201 | + raise HTTPException(status_code=500, detail=f"Failed to store video metadata: {exc}") | ||
| 202 | + | ||
| 203 | +@app.post("/generate", response_model=ImageGenerationResponse) | ||
| 204 | +async def generate_image(payload: ImageGenerationPayload): | ||
| 205 | + request_params_data = payload.model_dump(); body = {k: v for k, v in request_params_data.items() if v is not None and k != "author_id"} | ||
| 206 | + if "seed" not in body: body["seed"] = secrets.randbelow(1_000_000_000) | ||
| 207 | + request_params_data["seed"] = body["seed"]; request_params = ImageGenerationPayload(**request_params_data) | ||
| 208 | + url = f"{Z_IMAGE_BASE_URL}/generate" | ||
| 209 | + try: | ||
| 210 | + resp = await app.state.http.post(url, json=body) | ||
| 211 | + resp.raise_for_status() | ||
| 212 | + data = resp.json() | ||
| 213 | + image_url = data.get("url") or f"data:image/png;base64,{data.get('image')}" | ||
| 214 | + stored_item_data = { | ||
| 215 | + "id": data.get("id") or secrets.token_hex(16), | ||
| 216 | + "prompt": payload.prompt, | ||
| 217 | + "width": payload.width, | ||
| 218 | + "height": payload.height, | ||
| 219 | + "num_inference_steps": payload.num_inference_steps, | ||
| 220 | + "guidance_scale": payload.guidance_scale, | ||
| 221 | + "seed": request_params.seed, | ||
| 222 | + "url": image_url, | ||
| 223 | + "author_id": payload.author_id, | ||
| 224 | + "negative_prompt": payload.negative_prompt, | ||
| 225 | + } | ||
| 226 | + stored = image_store.add_item(GalleryImage(**stored_item_data)) | ||
| 227 | + return ImageGenerationResponse(image=data.get("image"), url=data.get("url"), time_taken=data.get("time_taken", 0.0), request_params=request_params, gallery_item=GalleryImage.model_validate(stored)) | ||
| 228 | + except httpx.RequestError as exc: raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}") | ||
| 229 | + except Exception as exc: raise HTTPException(status_code=500, detail=f"An error occurred: {exc}") | ||
| 246 | 230 | ||
| 247 | @app.get("/admin/whitelist") | 231 | @app.get("/admin/whitelist") |
| 248 | async def get_whitelist() -> dict: | 232 | async def get_whitelist() -> dict: |
| 249 | return {"whitelist": whitelist_store.get_all()} | 233 | return {"whitelist": whitelist_store.get_all()} |
| 250 | - | ||
| 251 | - | ||
| 252 | @app.post("/admin/whitelist") | 234 | @app.post("/admin/whitelist") |
| 253 | async def add_whitelist(user_ids: List[str]) -> dict: | 235 | async def add_whitelist(user_ids: List[str]) -> dict: |
| 254 | whitelist_store.add_users(user_ids) | 236 | whitelist_store.add_users(user_ids) |
| 255 | return {"status": "ok", "whitelist": whitelist_store.get_all()} | 237 | return {"status": "ok", "whitelist": whitelist_store.get_all()} |
| 256 | - | ||
| 257 | - | ||
| 258 | @app.delete("/admin/whitelist/{user_id}") | 238 | @app.delete("/admin/whitelist/{user_id}") |
| 259 | async def remove_whitelist(user_id: str) -> dict: | 239 | async def remove_whitelist(user_id: str) -> dict: |
| 260 | whitelist_store.remove_user(user_id) | 240 | whitelist_store.remove_user(user_id) |
| 261 | return {"status": "ok", "whitelist": whitelist_store.get_all()} | 241 | return {"status": "ok", "whitelist": whitelist_store.get_all()} |
| 262 | 242 | ||
| 263 | - | ||
| 264 | -@app.post("/likes/{image_id}") | ||
| 265 | -async def toggle_like( | ||
| 266 | - image_id: str, | ||
| 267 | - user_id: str = Query(..., alias="userId") | ||
| 268 | -) -> dict: | ||
| 269 | - """Toggle like status for an image by a user.""" | ||
| 270 | - updated_image = gallery_store.toggle_like(image_id, user_id) | ||
| 271 | - if not updated_image: | ||
| 272 | - raise HTTPException(status_code=404, detail="Image not found") | ||
| 273 | - return updated_image | ||
| 274 | - | ||
| 275 | - | 243 | +# Redirect old /gallery to /gallery/images for backward compatibility |
| 276 | @app.get("/gallery") | 244 | @app.get("/gallery") |
| 277 | -async def gallery( | ||
| 278 | - limit: int = Query(200, ge=1, le=1000), | ||
| 279 | - author_id: Optional[str] = Query(default=None, alias="authorId"), | ||
| 280 | -) -> dict: | ||
| 281 | - """Return the persisted gallery images, optionally filtered by author.""" | ||
| 282 | - images = gallery_store.list_images() | ||
| 283 | - if author_id: | ||
| 284 | - images = [item for item in images if item.get("authorId") == author_id] | ||
| 285 | - return {"images": images[:limit]} | ||
| 286 | - | ||
| 287 | - | ||
| 288 | -@app.post("/generate", response_model=ImageGenerationResponse) | ||
| 289 | -async def generate_image(payload: ImageGenerationPayload) -> ImageGenerationResponse: | ||
| 290 | - request_params_data = payload.model_dump() | ||
| 291 | - body = { | ||
| 292 | - key: value | ||
| 293 | - for key, value in request_params_data.items() | ||
| 294 | - if value is not None and key != "author_id" | ||
| 295 | - } | ||
| 296 | - if "seed" not in body: | ||
| 297 | - body["seed"] = secrets.randbelow(1_000_000_000) | ||
| 298 | - request_params_data["seed"] = body["seed"] | ||
| 299 | - request_params = ImageGenerationPayload(**request_params_data) | ||
| 300 | - url = f"{Z_IMAGE_BASE_URL}/generate" | ||
| 301 | - | ||
| 302 | - try: | ||
| 303 | - resp = await app.state.http.post(url, json=body) | ||
| 304 | - except httpx.RequestError as exc: # pragma: no cover - network errors only | ||
| 305 | - raise HTTPException(status_code=502, detail=f"Z-Image service unreachable: {exc}") from exc | ||
| 306 | - | ||
| 307 | - if resp.status_code != 200: | ||
| 308 | - raise HTTPException(status_code=resp.status_code, detail=f"Z-Image error: {resp.text}") | ||
| 309 | - | ||
| 310 | - data = resp.json() | ||
| 311 | - image = data.get("image") | ||
| 312 | - image_url = data.get("url") | ||
| 313 | - if not image and not image_url: | ||
| 314 | - raise HTTPException(status_code=502, detail=f"Malformed response from Z-Image: {data}") | ||
| 315 | - | ||
| 316 | - stored_image: Optional[GalleryImage] = None | ||
| 317 | - try: | ||
| 318 | - stored = gallery_store.add_image( | ||
| 319 | - GalleryImage( | ||
| 320 | - id=data.get("id") or secrets.token_hex(16), | ||
| 321 | - prompt=payload.prompt, | ||
| 322 | - width=payload.width, | ||
| 323 | - height=payload.height, | ||
| 324 | - num_inference_steps=payload.num_inference_steps, | ||
| 325 | - guidance_scale=payload.guidance_scale, | ||
| 326 | - seed=request_params.seed, | ||
| 327 | - url=image_url or f"data:image/png;base64,{image}", | ||
| 328 | - author_id=payload.author_id, | ||
| 329 | - negative_prompt=payload.negative_prompt, | ||
| 330 | - ) | ||
| 331 | - ) | ||
| 332 | - stored_image = GalleryImage.model_validate(stored) | ||
| 333 | - except Exception as exc: # pragma: no cover - diagnostics only | ||
| 334 | - # Persisting gallery data should not block the response | ||
| 335 | - print(f"[WARN] Failed to store gallery image: {exc}") | ||
| 336 | - | ||
| 337 | - return ImageGenerationResponse( | ||
| 338 | - image=image, | ||
| 339 | - url=image_url, | ||
| 340 | - time_taken=float(data.get("time_taken", 0.0)), | ||
| 341 | - error=data.get("error"), | ||
| 342 | - request_params=request_params, | ||
| 343 | - gallery_item=stored_image, | ||
| 344 | - ) | 245 | +async def gallery(limit: int = Query(200, ge=1, le=1000), author_id: Optional[str] = Query(None, alias="authorId")): |
| 246 | + return await gallery_images(limit=limit, author_id=author_id) |
| 1 | import React, { useState, useEffect, useMemo, useCallback } from 'react'; | 1 | import React, { useState, useEffect, useMemo, useCallback } from 'react'; |
| 2 | import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types'; | 2 | import { ImageItem, ImageGenerationParams, UserProfile, VideoStatus } from './types'; |
| 3 | -import { SHOWCASE_IMAGES, ADMIN_ID } from './constants'; | 3 | +import { SHOWCASE_IMAGES, ADMIN_ID, VIDEO_OSS_BASE_URL } from './constants'; |
| 4 | import { generateImage } from './services/imageService'; | 4 | import { generateImage } from './services/imageService'; |
| 5 | -import { submitVideoJob, pollVideoStatus, getVideoResultUrl } from './services/videoService'; | ||
| 6 | -import { fetchGallery, toggleLike } from './services/galleryService'; | 5 | +import { submitVideoJob, pollVideoStatus } from './services/videoService'; |
| 6 | +import { fetchGallery, fetchVideoGallery, saveVideo, 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'; |
| 9 | import HistoryBar from './components/HistoryBar'; | 9 | import HistoryBar from './components/HistoryBar'; |
| @@ -13,93 +13,86 @@ import AuthModal from './components/AuthModal'; | @@ -13,93 +13,86 @@ 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, Hourglass } 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'; | ||
| 17 | -const STORAGE_KEY_VIDEO_DATA = 'z-video-gallery-data-v1'; | ||
| 18 | const STORAGE_KEY_USER = 'z-image-user-profile'; | 16 | const STORAGE_KEY_USER = 'z-image-user-profile'; |
| 19 | const MIN_GALLERY_ITEMS = 8; | 17 | const MIN_GALLERY_ITEMS = 8; |
| 20 | 18 | ||
| 21 | -// --- Enums and Types --- | ||
| 22 | enum GalleryMode { | 19 | enum GalleryMode { |
| 23 | Image, | 20 | Image, |
| 24 | Video, | 21 | Video, |
| 25 | } | 22 | } |
| 26 | 23 | ||
| 27 | const App: React.FC = () => { | 24 | const App: React.FC = () => { |
| 28 | - // --- State: User --- | 25 | + // State |
| 29 | const [currentUser, setCurrentUser] = useState<UserProfile | null>(null); | 26 | const [currentUser, setCurrentUser] = useState<UserProfile | null>(null); |
| 30 | const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); | 27 | const [isAuthModalOpen, setIsAuthModalOpen] = useState(false); |
| 31 | - const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false); | ||
| 32 | - | ||
| 33 | - // --- State: UI --- | ||
| 34 | const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image); | 28 | const [galleryMode, setGalleryMode] = useState<GalleryMode>(GalleryMode.Image); |
| 35 | - | ||
| 36 | - // --- State: Data --- | ||
| 37 | - const [images, setImages] = useState<ImageItem[]>(() => { | ||
| 38 | - try { | ||
| 39 | - const saved = localStorage.getItem(STORAGE_KEY_DATA); | ||
| 40 | - if (saved) return JSON.parse(saved); | ||
| 41 | - } catch (e) { console.error(e); } | ||
| 42 | - return SHOWCASE_IMAGES; | ||
| 43 | - }); | ||
| 44 | - const [videos, setVideos] = useState<ImageItem[]>(() => { | ||
| 45 | - try { | ||
| 46 | - const saved = localStorage.getItem(STORAGE_KEY_VIDEO_DATA); | ||
| 47 | - if (saved) return JSON.parse(saved); | ||
| 48 | - } catch(e) { console.error(e); } | ||
| 49 | - return []; | ||
| 50 | - }); | ||
| 51 | - | ||
| 52 | - | 29 | + const [images, setImages] = useState<ImageItem[]>(SHOWCASE_IMAGES); |
| 30 | + const [videos, setVideos] = useState<ImageItem[]>([]); | ||
| 53 | const [isGenerating, setIsGenerating] = useState(false); | 31 | const [isGenerating, setIsGenerating] = useState(false); |
| 54 | const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null); | 32 | const [videoStatus, setVideoStatus] = useState<VideoStatus | null>(null); |
| 55 | const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); | 33 | const [selectedImage, setSelectedImage] = useState<ImageItem | null>(null); |
| 56 | const [error, setError] = useState<string | null>(null); | 34 | const [error, setError] = useState<string | null>(null); |
| 57 | const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); | 35 | const [incomingParams, setIncomingParams] = useState<ImageGenerationParams | null>(null); |
| 58 | - | ||
| 59 | - // --- State: Admin/Edit --- | ||
| 60 | const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); | 36 | const [isAdminModalOpen, setIsAdminModalOpen] = useState(false); |
| 37 | + const [isWhitelistModalOpen, setIsWhitelistModalOpen] = useState(false); | ||
| 61 | const [editingImage, setEditingImage] = useState<ImageItem | null>(null); | 38 | const [editingImage, setEditingImage] = useState<ImageItem | null>(null); |
| 62 | 39 | ||
| 63 | const isAdmin = currentUser?.employeeId === ADMIN_ID; | 40 | const isAdmin = currentUser?.employeeId === ADMIN_ID; |
| 64 | const isGeneratingVideo = videoStatus !== null; | 41 | const isGeneratingVideo = videoStatus !== null; |
| 65 | 42 | ||
| 66 | - | ||
| 67 | - // GLOBAL GALLERY: Everyone sees everything, sorted by likes | ||
| 68 | - const sortedImages = useMemo(() => { | ||
| 69 | - return [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0)); | ||
| 70 | - }, [images]); | ||
| 71 | - | ||
| 72 | - // USER HISTORY: Only current user's generations | 43 | + const sortedImages = useMemo(() => [...images].sort((a, b) => (b.likes || 0) - (a.likes || 0)), [images]); |
| 44 | + const sortedVideos = useMemo(() => [...videos].sort((a, b) => b.createdAt - a.createdAt), [videos]); | ||
| 45 | + | ||
| 73 | const userHistory = useMemo(() => { | 46 | const userHistory = useMemo(() => { |
| 74 | if (!currentUser) return []; | 47 | if (!currentUser) return []; |
| 75 | - return images | ||
| 76 | - .filter(img => img.authorId === currentUser.employeeId) | ||
| 77 | - .sort((a, b) => b.createdAt - a.createdAt); | 48 | + return images.filter(img => img.authorId === currentUser.employeeId).sort((a, b) => b.createdAt - a.createdAt); |
| 78 | }, [images, currentUser]); | 49 | }, [images, currentUser]); |
| 79 | 50 | ||
| 80 | const userVideoHistory = useMemo(() => { | 51 | const userVideoHistory = useMemo(() => { |
| 81 | if (!currentUser) return []; | 52 | if (!currentUser) return []; |
| 82 | - return videos | ||
| 83 | - .filter(vid => vid.authorId === currentUser.employeeId) | ||
| 84 | - .sort((a, b) => b.createdAt - a.createdAt); | 53 | + return videos.filter(vid => vid.authorId === currentUser.employeeId).sort((a, b) => b.createdAt - a.createdAt); |
| 85 | }, [videos, currentUser]); | 54 | }, [videos, currentUser]); |
| 86 | 55 | ||
| 56 | + // --- Auth Effect --- | ||
| 87 | useEffect(() => { | 57 | useEffect(() => { |
| 88 | const savedUser = localStorage.getItem(STORAGE_KEY_USER); | 58 | const savedUser = localStorage.getItem(STORAGE_KEY_USER); |
| 89 | if (savedUser) setCurrentUser(JSON.parse(savedUser)); | 59 | if (savedUser) setCurrentUser(JSON.parse(savedUser)); |
| 90 | else setIsAuthModalOpen(true); | 60 | else setIsAuthModalOpen(true); |
| 91 | }, []); | 61 | }, []); |
| 92 | 62 | ||
| 93 | - useEffect(() => { | ||
| 94 | - try { localStorage.setItem(STORAGE_KEY_DATA, JSON.stringify(images)); } | ||
| 95 | - catch (e) { console.error("Storage full", e); } | ||
| 96 | - }, [images]); | 63 | + // --- Data Sync --- |
| 64 | + const syncImageGallery = useCallback(async () => { | ||
| 65 | + try { | ||
| 66 | + const remoteImages = await fetchGallery(); | ||
| 67 | + setImages(prev => { | ||
| 68 | + const normalized = remoteImages.map(img => ({ ...img, isLikedByCurrentUser: currentUser ? (img.likedBy || []).includes(currentUser.employeeId) : false })); | ||
| 69 | + if (normalized.length >= MIN_GALLERY_ITEMS) return normalized; | ||
| 70 | + const existingIds = new Set(normalized.map(img => img.id)); | ||
| 71 | + const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice(0, MIN_GALLERY_ITEMS - normalized.length); | ||
| 72 | + return [...normalized, ...filler]; | ||
| 73 | + }); | ||
| 74 | + } catch (err) { console.error("Failed to sync image gallery", err); } | ||
| 75 | + }, [currentUser]); | ||
| 76 | + | ||
| 77 | + const syncVideoGallery = useCallback(async () => { | ||
| 78 | + try { | ||
| 79 | + const remoteVideos = await fetchVideoGallery(); | ||
| 80 | + setVideos(remoteVideos.map(vid => ({ ...vid, isLikedByCurrentUser: currentUser ? (vid.likedBy || []).includes(currentUser.employeeId) : false }))); | ||
| 81 | + } catch (err) { console.error("Failed to sync video gallery", err); } | ||
| 82 | + }, [currentUser]); | ||
| 97 | 83 | ||
| 98 | useEffect(() => { | 84 | useEffect(() => { |
| 99 | - try { localStorage.setItem(STORAGE_KEY_VIDEO_DATA, JSON.stringify(videos)); } | ||
| 100 | - catch (e) { console.error("Storage full for videos", e); } | ||
| 101 | - }, [videos]); | 85 | + syncImageGallery(); |
| 86 | + syncVideoGallery(); | ||
| 87 | + const interval = setInterval(() => { | ||
| 88 | + syncImageGallery(); | ||
| 89 | + syncVideoGallery(); | ||
| 90 | + }, 30000); | ||
| 91 | + return () => clearInterval(interval); | ||
| 92 | + }, [syncImageGallery, syncVideoGallery]); | ||
| 93 | + | ||
| 102 | 94 | ||
| 95 | + // --- Handlers --- | ||
| 103 | const handleLogin = (employeeId: string) => { | 96 | const handleLogin = (employeeId: string) => { |
| 104 | const user: UserProfile = { employeeId, hasAccess: true }; | 97 | const user: UserProfile = { employeeId, hasAccess: true }; |
| 105 | setCurrentUser(user); | 98 | setCurrentUser(user); |
| @@ -108,73 +101,29 @@ const App: React.FC = () => { | @@ -108,73 +101,29 @@ const App: React.FC = () => { | ||
| 108 | }; | 101 | }; |
| 109 | 102 | ||
| 110 | const handleLogout = () => { | 103 | const handleLogout = () => { |
| 111 | - if(confirm("确定要退出登录吗?")) { | 104 | + if (confirm("确定要退出登录吗?")) { |
| 112 | localStorage.removeItem(STORAGE_KEY_USER); | 105 | localStorage.removeItem(STORAGE_KEY_USER); |
| 113 | setCurrentUser(null); | 106 | setCurrentUser(null); |
| 114 | setIsAuthModalOpen(true); | 107 | setIsAuthModalOpen(true); |
| 115 | } | 108 | } |
| 116 | }; | 109 | }; |
| 117 | 110 | ||
| 118 | - const syncGallery = useCallback(async () => { | ||
| 119 | - if (galleryMode === GalleryMode.Video) { | ||
| 120 | - // We are not syncing videos from a central gallery in this version. | ||
| 121 | - return; | ||
| 122 | - } | ||
| 123 | - try { | ||
| 124 | - const remoteImages = await fetchGallery(); | ||
| 125 | - setImages(prev => { | ||
| 126 | - const normalized = remoteImages.map(img => { | ||
| 127 | - const isLiked = img.likedBy && currentUser | ||
| 128 | - ? img.likedBy.includes(currentUser.employeeId) | ||
| 129 | - : false; | ||
| 130 | - return { | ||
| 131 | - ...img, | ||
| 132 | - likes: img.likes, // Trust server | ||
| 133 | - isLikedByCurrentUser: isLiked, | ||
| 134 | - }; | ||
| 135 | - }); | ||
| 136 | - | ||
| 137 | - if (normalized.length >= MIN_GALLERY_ITEMS) { | ||
| 138 | - return normalized; | ||
| 139 | - } | ||
| 140 | - | ||
| 141 | - const existingIds = new Set(normalized.map(img => img.id)); | ||
| 142 | - const filler = SHOWCASE_IMAGES.filter(img => !existingIds.has(img.id)).slice( | ||
| 143 | - 0, | ||
| 144 | - Math.max(0, MIN_GALLERY_ITEMS - normalized.length) | ||
| 145 | - ); | ||
| 146 | - | ||
| 147 | - return [...normalized, ...filler]; | ||
| 148 | - }); | ||
| 149 | - } catch (err) { | ||
| 150 | - console.error("Failed to sync gallery", err); | ||
| 151 | - } | ||
| 152 | - }, [currentUser, galleryMode]); | ||
| 153 | - | ||
| 154 | - useEffect(() => { | ||
| 155 | - syncGallery(); | ||
| 156 | - const interval = setInterval(syncGallery, 30000); | ||
| 157 | - return () => clearInterval(interval); | ||
| 158 | - }, [syncGallery]); | ||
| 159 | - | ||
| 160 | const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => { | 111 | const handleGenerateVideo = async (params: ImageGenerationParams, imageFile: File) => { |
| 161 | - if (!currentUser) { | ||
| 162 | - setIsAuthModalOpen(true); | ||
| 163 | - return; | ||
| 164 | - } | 112 | + if (!currentUser) { setIsAuthModalOpen(true); return; } |
| 165 | setError(null); | 113 | setError(null); |
| 166 | setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); | 114 | setVideoStatus({ status: 'submitting', message: '提交中...', task_id: 'temp' }); |
| 167 | 115 | ||
| 168 | try { | 116 | try { |
| 169 | - const taskId = await submitVideoJob(params.prompt, imageFile); | ||
| 170 | - | ||
| 171 | - const finalStatus = await pollVideoStatus(taskId, (statusUpdate) => { | ||
| 172 | - setVideoStatus(statusUpdate); | ||
| 173 | - }); | 117 | + const taskId = await submitVideoJob(params.prompt, imageFile, currentUser.employeeId); |
| 118 | + const finalStatus = await pollVideoStatus(taskId, setVideoStatus); | ||
| 174 | 119 | ||
| 175 | - const newVideo: ImageItem = { | 120 | + if (!finalStatus.video_filename) { |
| 121 | + throw new Error("视频生成完成,但未找到有效的视频文件名。"); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + const newVideoData: ImageItem = { | ||
| 176 | id: `vid-${Date.now()}`, | 125 | id: `vid-${Date.now()}`, |
| 177 | - url: getVideoResultUrl(taskId), | 126 | + url: `${VIDEO_OSS_BASE_URL}/${finalStatus.video_filename}`, |
| 178 | prompt: params.prompt, | 127 | prompt: params.prompt, |
| 179 | authorId: currentUser.employeeId, | 128 | authorId: currentUser.employeeId, |
| 180 | createdAt: Date.now(), | 129 | createdAt: Date.now(), |
| @@ -182,63 +131,34 @@ const App: React.FC = () => { | @@ -182,63 +131,34 @@ const App: React.FC = () => { | ||
| 182 | isLikedByCurrentUser: false, | 131 | isLikedByCurrentUser: false, |
| 183 | generationTime: finalStatus.processing_time, | 132 | generationTime: finalStatus.processing_time, |
| 184 | }; | 133 | }; |
| 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 | 134 | + |
| 135 | + const savedVideo = await saveVideo(newVideoData); | ||
| 136 | + setVideos(prev => [savedVideo, ...prev]); | ||
| 191 | 137 | ||
| 138 | + setTimeout(() => setVideoStatus(null), 3000); | ||
| 192 | } catch (err: any) { | 139 | } catch (err: any) { |
| 193 | console.error(err); | 140 | console.error(err); |
| 194 | setError("视频生成失败。请确保视频生成服务正常运行。"); | 141 | setError("视频生成失败。请确保视频生成服务正常运行。"); |
| 195 | - setVideoStatus(null); // Clear immediately on error | 142 | + setVideoStatus(null); |
| 196 | } | 143 | } |
| 197 | }; | 144 | }; |
| 198 | 145 | ||
| 199 | const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => { | 146 | const handleGenerate = async (uiParams: ImageGenerationParams, imageFile?: File) => { |
| 200 | if (galleryMode === GalleryMode.Video) { | 147 | if (galleryMode === GalleryMode.Video) { |
| 201 | - if (!imageFile) { | ||
| 202 | - alert("请上传一张图片以生成视频。"); | ||
| 203 | - return; | ||
| 204 | - } | ||
| 205 | - handleGenerateVideo(uiParams, imageFile); | ||
| 206 | - return; | ||
| 207 | - } | ||
| 208 | - | ||
| 209 | - if (!currentUser) { | ||
| 210 | - setIsAuthModalOpen(true); | 148 | + if (!imageFile) { alert("请上传一张图片以生成视频。"); return; } |
| 149 | + handleGenerateVideo(uiParams, imageFile); | ||
| 211 | return; | 150 | return; |
| 212 | } | 151 | } |
| 213 | - | 152 | + if (!currentUser) { setIsAuthModalOpen(true); return; } |
| 214 | setIsGenerating(true); | 153 | setIsGenerating(true); |
| 215 | setError(null); | 154 | setError(null); |
| 216 | - setIncomingParams(null); // Reset syncing state once action starts | ||
| 217 | - | 155 | + setIncomingParams(null); |
| 218 | try { | 156 | try { |
| 219 | const result = await generateImage(uiParams, currentUser.employeeId); | 157 | const result = await generateImage(uiParams, currentUser.employeeId); |
| 220 | - const serverImage = result.galleryItem | ||
| 221 | - ? { ...result.galleryItem, isLikedByCurrentUser: false } | ||
| 222 | - : null; | ||
| 223 | - | ||
| 224 | - const newImage: ImageItem = serverImage || { | ||
| 225 | - id: Date.now().toString(), | ||
| 226 | - url: result.imageUrl, | ||
| 227 | - createdAt: Date.now(), | ||
| 228 | - authorId: currentUser.employeeId, | ||
| 229 | - likes: 0, | ||
| 230 | - isLikedByCurrentUser: false, | ||
| 231 | - ...uiParams | ||
| 232 | - }; | ||
| 233 | - | ||
| 234 | - setImages(prev => { | ||
| 235 | - const existing = prev.filter(img => img.id !== newImage.id); | ||
| 236 | - return [newImage, ...existing]; | ||
| 237 | - }); | ||
| 238 | - | ||
| 239 | - if (!serverImage) { | ||
| 240 | - await syncGallery(); | ||
| 241 | - } | 158 | + const serverImage = result.galleryItem ? { ...result.galleryItem, isLikedByCurrentUser: false } : null; |
| 159 | + const newImage: ImageItem = serverImage || { id: Date.now().toString(), url: result.imageUrl, createdAt: Date.now(), authorId: currentUser.employeeId, likes: 0, isLikedByCurrentUser: false, ...uiParams }; | ||
| 160 | + setImages(prev => [newImage, ...prev.filter(img => img.id !== newImage.id)]); | ||
| 161 | + if (!serverImage) await syncImageGallery(); | ||
| 242 | } catch (err: any) { | 162 | } catch (err: any) { |
| 243 | console.error(err); | 163 | console.error(err); |
| 244 | setError("生成失败。请确保服务器正常运行。"); | 164 | setError("生成失败。请确保服务器正常运行。"); |
| @@ -247,77 +167,55 @@ const App: React.FC = () => { | @@ -247,77 +167,55 @@ const App: React.FC = () => { | ||
| 247 | } | 167 | } |
| 248 | }; | 168 | }; |
| 249 | 169 | ||
| 250 | - const handleLike = async (image: ImageItem) => { | ||
| 251 | - if (!currentUser) { | ||
| 252 | - setIsAuthModalOpen(true); | ||
| 253 | - return; | ||
| 254 | - } | 170 | + const handleLike = async (item: ImageItem) => { |
| 171 | + if (!currentUser) { setIsAuthModalOpen(true); return; } | ||
| 172 | + const isVideo = item.id.startsWith('vid-'); | ||
| 173 | + const stateSetter = isVideo ? setVideos : setImages; | ||
| 255 | 174 | ||
| 256 | - // Optimistic update | ||
| 257 | - const previousImages = [...images]; | ||
| 258 | - setImages(prev => prev.map(img => { | ||
| 259 | - if (img.id === image.id) { | ||
| 260 | - const isLiked = !!img.isLikedByCurrentUser; | ||
| 261 | - return { | ||
| 262 | - ...img, | ||
| 263 | - isLikedByCurrentUser: !isLiked, | ||
| 264 | - likes: isLiked ? Math.max(0, (img.likes || 0) - 1) : (img.likes || 0) + 1 | ||
| 265 | - }; | 175 | + stateSetter(prev => prev.map(i => { |
| 176 | + if (i.id === item.id) { | ||
| 177 | + const isLiked = !!i.isLikedByCurrentUser; | ||
| 178 | + return { ...i, isLikedByCurrentUser: !isLiked, likes: (i.likes || 0) + (isLiked ? -1 : 1) }; | ||
| 266 | } | 179 | } |
| 267 | - return img; | 180 | + return i; |
| 268 | })); | 181 | })); |
| 269 | 182 | ||
| 270 | try { | 183 | try { |
| 271 | - await toggleLike(image.id, currentUser.employeeId); | 184 | + await toggleLike(item.id, currentUser.employeeId); |
| 272 | } catch (e) { | 185 | } catch (e) { |
| 273 | - console.error("Like failed", e); | ||
| 274 | - setImages(previousImages); // Revert | ||
| 275 | - alert("操作失败"); | 186 | + console.error("Like failed", e); |
| 187 | + if (isVideo) syncVideoGallery(); else syncImageGallery(); | ||
| 188 | + alert("操作失败"); | ||
| 276 | } | 189 | } |
| 277 | }; | 190 | }; |
| 278 | 191 | ||
| 279 | const handleGenerateSimilar = (params: ImageGenerationParams) => { | 192 | const handleGenerateSimilar = (params: ImageGenerationParams) => { |
| 280 | setIncomingParams(params); | 193 | setIncomingParams(params); |
| 281 | const banner = document.getElementById('similar-feedback'); | 194 | const banner = document.getElementById('similar-feedback'); |
| 282 | - if (banner) { | ||
| 283 | - banner.style.display = 'flex'; | ||
| 284 | - setTimeout(() => { banner.style.display = 'none'; }, 3000); | ||
| 285 | - } | 195 | + if (banner) { banner.style.display = 'flex'; setTimeout(() => { banner.style.display = 'none'; }, 3000); } |
| 286 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); | 196 | window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); |
| 287 | }; | 197 | }; |
| 288 | - | ||
| 289 | - // --- Management (ADMIN) --- | 198 | + |
| 199 | + // Admin handlers... | ||
| 290 | const handleOpenCreateModal = () => { if (isAdmin) { setEditingImage(null); setIsAdminModalOpen(true); } }; | 200 | const handleOpenCreateModal = () => { if (isAdmin) { setEditingImage(null); setIsAdminModalOpen(true); } }; |
| 291 | const handleOpenEditModal = (image: ImageItem) => { if (isAdmin) { setEditingImage(image); setSelectedImage(null); setIsAdminModalOpen(true); } }; | 201 | const handleOpenEditModal = (image: ImageItem) => { if (isAdmin) { setEditingImage(image); setSelectedImage(null); setIsAdminModalOpen(true); } }; |
| 292 | - const handleSaveImage = (savedImage: ImageItem) => { | ||
| 293 | - setImages(prev => { | ||
| 294 | - const exists = prev.some(img => img.id === savedImage.id); | ||
| 295 | - if (exists) return prev.map(img => img.id === savedImage.id ? savedImage : img); | ||
| 296 | - return [{ ...savedImage, authorId: currentUser?.employeeId || 'ADMIN', likes: savedImage.likes || 0 }, ...prev]; | ||
| 297 | - }); | ||
| 298 | - }; | 202 | + const handleSaveImage = (savedImage: ImageItem) => setImages(prev => { const exists = prev.some(img => img.id === savedImage.id); if (exists) return prev.map(img => img.id === savedImage.id ? savedImage : img); return [{ ...savedImage, authorId: currentUser?.employeeId || 'ADMIN', likes: savedImage.likes || 0 }, ...prev]; }); |
| 299 | const handleDeleteImage = (id: string) => { if (isAdmin) setImages(prev => prev.filter(img => img.id !== id)); }; | 203 | const handleDeleteImage = (id: string) => { if (isAdmin) setImages(prev => prev.filter(img => img.id !== id)); }; |
| 300 | const handleResetData = () => { if (isAdmin && confirm('警告:确定要重置为初始演示数据吗?')) { setImages(SHOWCASE_IMAGES); } }; | 204 | const handleResetData = () => { if (isAdmin && confirm('警告:确定要重置为初始演示数据吗?')) { setImages(SHOWCASE_IMAGES); } }; |
| 301 | 205 | ||
| 302 | const handleExportShowcase = () => { | 206 | const handleExportShowcase = () => { |
| 303 | const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true })); | 207 | const exportData = images.map(img => ({ ...img, isLikedByCurrentUser: false, isMock: true })); |
| 304 | - const fileContent = `...`; // Omitted for brevity | 208 | + const fileContent = `import { ImageItem } from './types';\n\nexport const SHOWCASE_IMAGES: ImageItem[] = ${JSON.stringify(exportData, null, 2)};`; |
| 305 | const blob = new Blob([fileContent], { type: 'text/typescript' }); | 209 | const blob = new Blob([fileContent], { type: 'text/typescript' }); |
| 306 | const a = document.createElement('a'); | 210 | const a = document.createElement('a'); |
| 307 | a.href = URL.createObjectURL(blob); | 211 | a.href = URL.createObjectURL(blob); |
| 308 | - a.download = 'constants.ts'; | 212 | + a.download = 'showcase_images.ts'; |
| 309 | a.click(); | 213 | a.click(); |
| 310 | }; | 214 | }; |
| 311 | 215 | ||
| 312 | return ( | 216 | return ( |
| 313 | <div className="min-h-screen bg-white text-gray-900 font-sans pb-40"> | 217 | <div className="min-h-screen bg-white text-gray-900 font-sans pb-40"> |
| 314 | <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} /> | 218 | <AuthModal isOpen={isAuthModalOpen} onLogin={handleLogin} /> |
| 315 | - | ||
| 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"> | ||
| 317 | - <Sparkles size={16} /> | ||
| 318 | - <span className="text-sm font-bold tracking-wide">参数已同步到输入框</span> | ||
| 319 | - </div> | ||
| 320 | - | ||
| 321 | <header className="px-6 py-6 md:px-12 md:py-8 flex justify-between items-end sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-100"> | 219 | <header className="px-6 py-6 md:px-12 md:py-8 flex justify-between items-end sticky top-0 z-40 bg-white/80 backdrop-blur-md border-b border-gray-100"> |
| 322 | <div> | 220 | <div> |
| 323 | <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-2">艺云-DESIGN</h1> | 221 | <h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-2">艺云-DESIGN</h1> |
| @@ -348,52 +246,27 @@ const App: React.FC = () => { | @@ -348,52 +246,27 @@ const App: React.FC = () => { | ||
| 348 | </header> | 246 | </header> |
| 349 | 247 | ||
| 350 | <div className="px-6 md:px-12 mt-6 mb-4 flex border-b"> | 248 | <div className="px-6 md:px-12 mt-6 mb-4 flex border-b"> |
| 351 | - <button | ||
| 352 | - onClick={() => setGalleryMode(GalleryMode.Image)} | ||
| 353 | - 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'}`} | ||
| 354 | - > | ||
| 355 | - 灵感图库 | ||
| 356 | - </button> | ||
| 357 | - <button | ||
| 358 | - onClick={() => setGalleryMode(GalleryMode.Video)} | ||
| 359 | - 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'}`} | ||
| 360 | - > | ||
| 361 | - 视频素材 | ||
| 362 | - </button> | 249 | + <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> |
| 250 | + <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> | ||
| 363 | </div> | 251 | </div> |
| 364 | 252 | ||
| 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>} | 253 | {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>} |
| 366 | 254 | ||
| 367 | <main> | 255 | <main> |
| 368 | - {isGenerating && ( // This is for image generation only | ||
| 369 | - <div className="w-full flex justify-center py-12"> | ||
| 370 | - <div className="flex flex-col items-center animate-pulse"> | ||
| 371 | - <Loader2 className="animate-spin text-purple-600 mb-3" size={40} /> | ||
| 372 | - <span className="text-gray-500 font-medium">绘图引擎全力启动中...</span> | ||
| 373 | - </div> | ||
| 374 | - </div> | 256 | + {isGenerating && ( |
| 257 | + <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></div></div> | ||
| 375 | )} | 258 | )} |
| 376 | 259 | ||
| 377 | {galleryMode === GalleryMode.Image ? ( | 260 | {galleryMode === GalleryMode.Image ? ( |
| 378 | <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/> | 261 | <MasonryGrid images={sortedImages} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId}/> |
| 379 | ) : ( | 262 | ) : ( |
| 380 | - <MasonryGrid images={videos} onImageClick={setSelectedImage} onLike={() => {}} currentUser={currentUser?.employeeId} isVideoGallery={true} /> | 263 | + <MasonryGrid images={sortedVideos} onImageClick={setSelectedImage} onLike={handleLike} currentUser={currentUser?.employeeId} isVideoGallery={true} /> |
| 381 | )} | 264 | )} |
| 382 | </main> | 265 | </main> |
| 383 | 266 | ||
| 384 | <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} /> | 267 | <HistoryBar images={galleryMode === GalleryMode.Image ? userHistory : userVideoHistory} onSelect={setSelectedImage} /> |
| 385 | - | ||
| 386 | <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} /> | 268 | <InputBar onGenerate={handleGenerate} isGenerating={isGenerating || isGeneratingVideo} incomingParams={incomingParams} isVideoMode={galleryMode === GalleryMode.Video} videoStatus={videoStatus} /> |
| 387 | - | ||
| 388 | - {selectedImage && ( | ||
| 389 | - <DetailModal | ||
| 390 | - image={selectedImage} | ||
| 391 | - onClose={() => setSelectedImage(null)} | ||
| 392 | - onEdit={isAdmin ? handleOpenEditModal : undefined} | ||
| 393 | - onGenerateSimilar={handleGenerateSimilar} | ||
| 394 | - /> | ||
| 395 | - )} | ||
| 396 | - | 269 | + {selectedImage && <DetailModal image={selectedImage} onClose={() => setSelectedImage(null)} onEdit={isAdmin ? handleOpenEditModal : undefined} onGenerateSimilar={handleGenerateSimilar}/>} |
| 397 | <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} /> | 270 | <AdminModal isOpen={isAdminModalOpen} onClose={() => setIsAdminModalOpen(false)} onSave={handleSaveImage} onDelete={handleDeleteImage} initialData={editingImage} /> |
| 398 | <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} /> | 271 | <WhitelistModal isOpen={isWhitelistModalOpen} onClose={() => setIsWhitelistModalOpen(false)} /> |
| 399 | </div> | 272 | </div> |
| @@ -23,12 +23,22 @@ const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => { | @@ -23,12 +23,22 @@ const HistoryBar: React.FC<HistoryBarProps> = ({ images, onSelect }) => { | ||
| 23 | onClick={() => onSelect(img)} | 23 | onClick={() => onSelect(img)} |
| 24 | className="flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 border-white dark:border-gray-800 shadow-sm hover:scale-105 hover:border-purple-400 transition-all" | 24 | className="flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 border-white dark:border-gray-800 shadow-sm hover:scale-105 hover:border-purple-400 transition-all" |
| 25 | > | 25 | > |
| 26 | - <img | ||
| 27 | - src={img.url} | ||
| 28 | - alt="History" | ||
| 29 | - className="w-full h-full object-cover" | ||
| 30 | - loading="lazy" | ||
| 31 | - /> | 26 | + {img.id.startsWith('vid-') ? ( |
| 27 | + <video | ||
| 28 | + src={img.url} | ||
| 29 | + className="w-full h-full object-cover" | ||
| 30 | + muted | ||
| 31 | + playsInline | ||
| 32 | + preload="metadata" | ||
| 33 | + /> | ||
| 34 | + ) : ( | ||
| 35 | + <img | ||
| 36 | + src={img.url} | ||
| 37 | + alt="History" | ||
| 38 | + className="w-full h-full object-cover" | ||
| 39 | + loading="lazy" | ||
| 40 | + /> | ||
| 41 | + )} | ||
| 32 | </button> | 42 | </button> |
| 33 | ))} | 43 | ))} |
| 34 | </div> | 44 | </div> |
| 1 | import { ImageItem } from './types'; | 1 | import { ImageItem } from './types'; |
| 2 | 2 | ||
| 3 | export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009"; | 3 | export const Z_IMAGE_DIRECT_BASE_URL = "http://106.120.52.146:39009"; |
| 4 | -export const TURBO_DIFFUSION_VIDEO_BASE_URL = "http://106.120.52.146:38000"; | 4 | + |
| 5 | +// Base URL for the TurboDiffusion AI service (submit job, poll status) | ||
| 6 | +export const TURBO_DIFFUSION_API_URL = "http://106.120.52.146:38000"; | ||
| 7 | + | ||
| 8 | +// Base URL for the OSS service that serves the final video files | ||
| 9 | +export const VIDEO_OSS_BASE_URL = "http://106.120.52.146:39997"; | ||
| 5 | 10 | ||
| 6 | const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim(); | 11 | const ENV_PROXY_URL = import.meta.env?.VITE_API_BASE_URL?.trim(); |
| 7 | const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0 | 12 | const DEFAULT_PROXY_URL = ENV_PROXY_URL && ENV_PROXY_URL.length > 0 |
| @@ -10,22 +10,65 @@ export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => { | @@ -10,22 +10,65 @@ export const fetchGallery = async (authorId?: string): Promise<ImageItem[]> => { | ||
| 10 | params.set('authorId', authorId); | 10 | params.set('authorId', authorId); |
| 11 | } | 11 | } |
| 12 | const query = params.toString(); | 12 | const query = params.toString(); |
| 13 | - const response = await fetch(`${API_BASE_URL}/gallery${query ? `?${query}` : ''}`); | 13 | + // Note: The backend endpoint was changed from /gallery to /gallery/images |
| 14 | + const response = await fetch(`${API_BASE_URL}/gallery/images${query ? `?${query}` : ''}`); | ||
| 14 | 15 | ||
| 15 | if (!response.ok) { | 16 | if (!response.ok) { |
| 16 | const errorText = await response.text(); | 17 | const errorText = await response.text(); |
| 17 | - throw new Error(`Gallery fetch failed (${response.status}): ${errorText}`); | 18 | + throw new Error(`Image gallery fetch failed (${response.status}): ${errorText}`); |
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | const data: GalleryResponse = await response.json(); | 21 | const data: GalleryResponse = await response.json(); |
| 21 | return data.images ?? []; | 22 | return data.images ?? []; |
| 22 | }; | 23 | }; |
| 23 | 24 | ||
| 24 | -export const toggleLike = async (imageId: string, userId: string): Promise<ImageItem> => { | 25 | +export const fetchVideoGallery = async (authorId?: string): Promise<ImageItem[]> => { |
| 25 | if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { | 26 | if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { |
| 26 | - throw new Error("Cannot like images in direct mode"); | 27 | + return []; |
| 28 | + } | ||
| 29 | + const params = new URLSearchParams(); | ||
| 30 | + if (authorId) { | ||
| 31 | + params.set('authorId', authorId); | ||
| 32 | + } | ||
| 33 | + const query = params.toString(); | ||
| 34 | + const response = await fetch(`${API_BASE_URL}/gallery/videos${query ? `?${query}` : ''}`); | ||
| 35 | + | ||
| 36 | + if (!response.ok) { | ||
| 37 | + const errorText = await response.text(); | ||
| 38 | + throw new Error(`Video gallery fetch failed (${response.status}): ${errorText}`); | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + // Assuming the response for videos is { videos: ImageItem[] } | ||
| 42 | + const data: { videos: ImageItem[] } = await response.json(); | ||
| 43 | + return data.videos ?? []; | ||
| 44 | +}; | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +export const saveVideo = async (videoData: ImageItem): Promise<ImageItem> => { | ||
| 48 | + if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { | ||
| 49 | + throw new Error("Cannot save videos in direct mode"); | ||
| 50 | + } | ||
| 51 | + const response = await fetch(`${API_BASE_URL}/gallery/videos`, { | ||
| 52 | + method: 'POST', | ||
| 53 | + headers: { | ||
| 54 | + 'Content-Type': 'application/json', | ||
| 55 | + }, | ||
| 56 | + body: JSON.stringify(videoData), | ||
| 57 | + }); | ||
| 58 | + | ||
| 59 | + if (!response.ok) { | ||
| 60 | + const errorText = await response.text(); | ||
| 61 | + throw new Error(`Save video failed (${response.status}): ${errorText}`); | ||
| 27 | } | 62 | } |
| 28 | - const response = await fetch(`${API_BASE_URL}/likes/${imageId}?userId=${userId}`, { | 63 | + return await response.json(); |
| 64 | +}; | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +export const toggleLike = async (itemId: string, userId: string): Promise<ImageItem> => { | ||
| 68 | + if (API_BASE_URL === Z_IMAGE_DIRECT_BASE_URL) { | ||
| 69 | + throw new Error("Cannot like items in direct mode"); | ||
| 70 | + } | ||
| 71 | + const response = await fetch(`${API_BASE_URL}/likes/${itemId}?userId=${userId}`, { | ||
| 29 | method: 'POST', | 72 | method: 'POST', |
| 30 | }); | 73 | }); |
| 31 | 74 | ||
| @@ -35,4 +78,4 @@ export const toggleLike = async (imageId: string, userId: string): Promise<Image | @@ -35,4 +78,4 @@ export const toggleLike = async (imageId: string, userId: string): Promise<Image | ||
| 35 | } | 78 | } |
| 36 | 79 | ||
| 37 | return await response.json(); | 80 | return await response.json(); |
| 38 | -}; | 81 | +}; |
| 1 | -import { TURBO_DIFFUSION_VIDEO_BASE_URL } from '../constants'; | 1 | +import { TURBO_DIFFUSION_API_URL } from '../constants'; |
| 2 | import { VideoStatus } from '../types'; | 2 | import { VideoStatus } from '../types'; |
| 3 | 3 | ||
| 4 | /** | 4 | /** |
| 5 | * Submits a video generation job to the backend. | 5 | * Submits a video generation job to the backend. |
| 6 | * @returns The task ID for the submitted job. | 6 | * @returns The task ID for the submitted job. |
| 7 | */ | 7 | */ |
| 8 | -export const submitVideoJob = async (prompt: string, image: File): Promise<string> => { | 8 | +export const submitVideoJob = async (prompt: string, image: File, authorId: string): Promise<string> => { |
| 9 | const formData = new FormData(); | 9 | const formData = new FormData(); |
| 10 | formData.append('prompt', prompt); | 10 | formData.append('prompt', prompt); |
| 11 | formData.append('image', image, image.name); | 11 | formData.append('image', image, image.name); |
| 12 | + formData.append('author_id', authorId); | ||
| 12 | 13 | ||
| 13 | - const submitRes = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/submit-job/`, { | 14 | + const submitRes = await fetch(`${TURBO_DIFFUSION_API_URL}/submit-job/`, { |
| 14 | method: 'POST', | 15 | method: 'POST', |
| 15 | body: formData, | 16 | body: formData, |
| 16 | }); | 17 | }); |
| @@ -37,7 +38,7 @@ export const pollVideoStatus = ( | @@ -37,7 +38,7 @@ export const pollVideoStatus = ( | ||
| 37 | return new Promise((resolve, reject) => { | 38 | return new Promise((resolve, reject) => { |
| 38 | const interval = setInterval(async () => { | 39 | const interval = setInterval(async () => { |
| 39 | try { | 40 | try { |
| 40 | - const res = await fetch(`${TURBO_DIFFUSION_VIDEO_BASE_URL}/status/${taskId}`); | 41 | + const res = await fetch(`${TURBO_DIFFUSION_API_URL}/status/${taskId}`); |
| 41 | if (!res.ok) { | 42 | if (!res.ok) { |
| 42 | // Stop polling on HTTP error | 43 | // Stop polling on HTTP error |
| 43 | clearInterval(interval); | 44 | clearInterval(interval); |
| @@ -63,12 +64,4 @@ export const pollVideoStatus = ( | @@ -63,12 +64,4 @@ export const pollVideoStatus = ( | ||
| 63 | } | 64 | } |
| 64 | }, 2000); // Poll every 2 seconds | 65 | }, 2000); // Poll every 2 seconds |
| 65 | }); | 66 | }); |
| 66 | -}; | ||
| 67 | - | ||
| 68 | - | ||
| 69 | -/** | ||
| 70 | - * Gets the final URL for a completed video task. | ||
| 71 | - */ | ||
| 72 | -export const getVideoResultUrl = (taskId: string): string => { | ||
| 73 | - return `${TURBO_DIFFUSION_VIDEO_BASE_URL}/result/${taskId}`; | ||
| 74 | }; | 67 | }; |
| @@ -48,4 +48,5 @@ export interface VideoStatus { | @@ -48,4 +48,5 @@ export interface VideoStatus { | ||
| 48 | message: string; | 48 | message: string; |
| 49 | queue_position?: number; | 49 | queue_position?: number; |
| 50 | processing_time?: number; | 50 | processing_time?: number; |
| 51 | + video_filename?: string; // The final filename of the video | ||
| 51 | } | 52 | } |
-
Please register or login to post a comment