flakego's picture
Deploy Image Services target=huggingface source=c02c0ad
1ec5369 verified
Raw
History Blame Contribute Delete
15.3 kB
from __future__ import annotations
import io
import logging
import os
from urllib.parse import urlparse
import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from starlette.background import BackgroundTask
from starlette.concurrency import run_in_threadpool
from PIL import Image
from features.doubao_watermark.service import run_remove_doubao
from services.douyin.service import DouyinParseError, resolve_douyin_share
from services.inpainting import service as inpainting_service
from services.ocr.routes import ocr_router, ocr_service
from services.youtube.service import (
YouTubeTranscriptError,
fetch_signed_caption,
fetch_youtube_transcript,
fetch_youtube_video,
)
logger = logging.getLogger(__name__)
DEPLOYMENT_TARGET = os.getenv("DEPLOYMENT_TARGET", "local").strip().lower()
SUB2API_ORIGIN_URL = os.getenv("SUB2API_ORIGIN_URL", "http://101.43.24.194").rstrip("/")
SUB2API_ORIGIN_HOST = os.getenv("SUB2API_ORIGIN_HOST", "101.43.24.194")
SUB2API_ORIGIN_TOKEN = os.getenv("SUB2API_ORIGIN_TOKEN", "")
XTOKEN_IMAGES_BASE_URL = os.getenv(
"XTOKEN_IMAGES_BASE_URL", "https://api.xtokenmirror.com/v1/images"
).rstrip("/")
XTOKEN_IMAGES_MAX_BODY_BYTES = 32 * 1024 * 1024
OCR_WARMUP_ON_STARTUP = os.getenv("OCR_WARMUP_ON_STARTUP", "0") == "1"
HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
class DouyinParseRequest(BaseModel):
share_text: str = Field(min_length=1, max_length=4096)
class YouTubeTranscriptRequest(BaseModel):
url: str = Field(min_length=1, max_length=2048)
languages: list[str] | None = Field(default=None, max_length=10)
class YouTubeInfoRequest(YouTubeTranscriptRequest):
include_transcript: bool = True
class YouTubeCaptionRequest(BaseModel):
url: str = Field(min_length=1, max_length=8192)
app = FastAPI(title="Image Services API")
app.include_router(ocr_router)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://image.goudaner.fun",
"https://watermark-blush.vercel.app",
"https://watermark-clipdalles-projects.vercel.app",
"https://watermark-clipdalle-clipdalles-projects.vercel.app",
],
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=[
"X-Inpaint-Mode",
"X-Inpaint-Backend",
"X-Inpaint-Elapsed",
"X-Doubao-Confidence",
"X-Doubao-Coverage",
"X-Doubao-Detection",
"X-Doubao-BBox",
],
)
async def _close_proxy_response(response: httpx.Response, client: httpx.AsyncClient) -> None:
try:
await response.aclose()
finally:
await client.aclose()
async def _proxy_sub2api(request: Request, upstream_path: str) -> StreamingResponse:
if not SUB2API_ORIGIN_TOKEN:
raise HTTPException(status_code=503, detail="Sub2API origin token is not configured")
target_url = httpx.URL(f"{SUB2API_ORIGIN_URL}{upstream_path}")
if request.url.query:
target_url = target_url.copy_with(query=request.url.query.encode("utf-8"))
request_headers = {
name: value
for name, value in request.headers.items()
if name.lower() not in HOP_BY_HOP_HEADERS | {"host", "content-length"}
}
request_headers["host"] = SUB2API_ORIGIN_HOST
request_headers["x-sub2api-origin-token"] = SUB2API_ORIGIN_TOKEN
timeout = httpx.Timeout(connect=20.0, read=None, write=120.0, pool=20.0)
client = httpx.AsyncClient(timeout=timeout, follow_redirects=False)
upstream_request = client.build_request(
request.method,
target_url,
headers=request_headers,
content=request.stream(),
)
try:
upstream_response = await client.send(upstream_request, stream=True)
except httpx.RequestError as exc:
await client.aclose()
logger.warning("Sub2API upstream request failed: %s", exc)
raise HTTPException(
status_code=502,
detail=f"Sub2API upstream unavailable: {exc}",
) from exc
response_headers = {
name: value
for name, value in upstream_response.headers.items()
if name.lower() not in HOP_BY_HOP_HEADERS
}
return StreamingResponse(
upstream_response.aiter_raw(),
status_code=upstream_response.status_code,
headers=response_headers,
background=BackgroundTask(_close_proxy_response, upstream_response, client),
)
@app.api_route(
"/v1/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def proxy_sub2api(request: Request, path: str) -> StreamingResponse:
return await _proxy_sub2api(request, f"/v1/{path}")
async def _proxy_xtoken_images(request: Request, operation: str) -> StreamingResponse:
if operation not in {"generations", "edits"}:
raise HTTPException(status_code=404, detail="Images API operation not found")
authorization = request.headers.get("authorization", "")
if not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=401, detail="XToken API key is required")
content_length = request.headers.get("content-length")
if content_length:
try:
if int(content_length) > XTOKEN_IMAGES_MAX_BODY_BYTES:
raise HTTPException(status_code=413, detail="Image request exceeds 32 MiB")
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid Content-Length header") from exc
body = await request.body()
if len(body) > XTOKEN_IMAGES_MAX_BODY_BYTES:
raise HTTPException(status_code=413, detail="Image request exceeds 32 MiB")
request_headers = {"authorization": authorization}
for name in ("content-type", "accept", "user-agent"):
value = request.headers.get(name)
if value:
request_headers[name] = value
client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=20.0, read=None, write=180.0, pool=20.0),
follow_redirects=False,
)
upstream_request = client.build_request(
"POST",
f"{XTOKEN_IMAGES_BASE_URL}/{operation}",
headers=request_headers,
content=body,
)
try:
upstream_response = await client.send(upstream_request, stream=True)
except httpx.RequestError as exc:
await client.aclose()
logger.warning("XToken Images upstream request failed: %s", exc)
raise HTTPException(
status_code=502,
detail=f"XToken Images API unavailable: {exc}",
) from exc
response_headers = {
name: value
for name, value in upstream_response.headers.items()
if name.lower() not in HOP_BY_HOP_HEADERS
and not name.lower().startswith("access-control-")
}
return StreamingResponse(
upstream_response.aiter_raw(),
status_code=upstream_response.status_code,
headers=response_headers,
background=BackgroundTask(_close_proxy_response, upstream_response, client),
)
@app.post("/xtoken-images/{operation}")
async def proxy_xtoken_images(request: Request, operation: str) -> StreamingResponse:
return await _proxy_xtoken_images(request, operation)
@app.on_event("startup")
def startup() -> None:
expected_sources = {
"huggingface": ("huggingface", "url"),
"modelscope": ("modelscope", "modelscope"),
"local": (inpainting_service.LAMA_MODEL_SOURCE, inpainting_service.MIGAN_MODEL_SOURCE),
}
if DEPLOYMENT_TARGET not in expected_sources:
raise RuntimeError(f"Unsupported DEPLOYMENT_TARGET: {DEPLOYMENT_TARGET}")
expected_lama, expected_migan = expected_sources[DEPLOYMENT_TARGET]
if (inpainting_service.LAMA_MODEL_SOURCE, inpainting_service.MIGAN_MODEL_SOURCE) != (expected_lama, expected_migan):
raise RuntimeError(
"Deployment/model source mismatch: "
f"target={DEPLOYMENT_TARGET}, lama={inpainting_service.LAMA_MODEL_SOURCE}, "
f"migan={inpainting_service.MIGAN_MODEL_SOURCE}"
)
if inpainting_service.LOAD_MODEL_ON_STARTUP:
for mode in inpainting_service.PRELOAD_WATERMARK_MODES:
if inpainting_service._normalize_mode(mode) == "fast":
inpainting_service._load_migan_model()
else:
inpainting_service._load_lama_session()
if OCR_WARMUP_ON_STARTUP:
ocr_service.registry.get("rapidocr").warmup()
@app.get("/")
def root() -> dict[str, object]:
return health()
@app.get("/health")
def health() -> dict[str, object]:
return {
"status": "ok",
"mode": "dual-selected-backends",
"deployment": {
"target": DEPLOYMENT_TARGET,
"model_sources": {
"lama": inpainting_service.LAMA_MODEL_SOURCE,
"migan": inpainting_service.MIGAN_MODEL_SOURCE,
},
},
"default_mode": inpainting_service.DEFAULT_MODE,
"supported_modes": inpainting_service.MODES,
"loaded": {
"quality": inpainting_service.LAMA_SESSION is not None,
"fast": inpainting_service.MIGAN_MODEL is not None,
},
}
@app.get("/douyin/health")
def douyin_health() -> dict[str, object]:
return {"ok": True, "service": "douyin-parser"}
@app.post("/douyin/parse")
async def parse_douyin(request: DouyinParseRequest) -> JSONResponse:
try:
result = await resolve_douyin_share(request.share_text)
except DouyinParseError as exc:
return JSONResponse(
status_code=400,
content={
"ok": False,
"result": None,
"error": {"code": exc.code, "message": exc.message},
},
)
return JSONResponse(
content={"ok": True, "result": result.to_dict(), "error": None}
)
@app.get("/youtube/health")
def youtube_health() -> dict[str, object]:
return {"ok": True, "service": "youtube-transcript"}
@app.post("/youtube/transcript")
async def youtube_transcript(request: YouTubeTranscriptRequest) -> JSONResponse:
try:
result = await run_in_threadpool(
fetch_youtube_transcript,
request.url,
request.languages,
)
except YouTubeTranscriptError as exc:
error: dict[str, object] = {"code": exc.code, "message": exc.message}
if exc.details:
error["details"] = exc.details
return JSONResponse(
status_code=400,
content={"ok": False, "result": None, "error": error},
)
return JSONResponse(
content={"ok": True, "result": result.to_dict(), "error": None}
)
@app.post("/youtube/info")
async def youtube_info(request: YouTubeInfoRequest) -> JSONResponse:
try:
result = await run_in_threadpool(
fetch_youtube_video,
request.url,
request.languages,
include_transcript=request.include_transcript,
)
except YouTubeTranscriptError as exc:
error: dict[str, object] = {"code": exc.code, "message": exc.message}
if exc.details:
error["details"] = exc.details
return JSONResponse(
status_code=400,
content={"ok": False, "result": None, "error": error},
)
return JSONResponse(content={"ok": True, "result": result, "error": None})
@app.post("/youtube/caption")
async def youtube_caption(request: YouTubeCaptionRequest) -> Response:
try:
content = await fetch_signed_caption(request.url)
except YouTubeTranscriptError as exc:
return JSONResponse(
status_code=400,
content={
"ok": False,
"result": None,
"error": {"code": exc.code, "message": exc.message},
},
)
return Response(content=content, media_type="application/json; charset=utf-8")
@app.post("/inpaint")
async def inpaint(
image: UploadFile = File(...),
mask: UploadFile = File(...),
mode: str = Form(inpainting_service.DEFAULT_MODE),
) -> Response:
try:
image_bytes = await image.read()
mask_bytes = await mask.read()
pil_image = Image.open(io.BytesIO(image_bytes))
pil_mask = Image.open(io.BytesIO(mask_bytes))
result, normalized_mode, elapsed = inpainting_service.run_inpaint(pil_image, pil_mask, mode=mode)
output = io.BytesIO()
result.save(output, format="PNG")
return Response(
content=output.getvalue(),
media_type="image/png",
headers={
"X-Inpaint-Mode": normalized_mode,
"X-Inpaint-Backend": inpainting_service.MODES[normalized_mode],
"X-Inpaint-Elapsed": f"{elapsed:.3f}",
},
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/remove-doubao")
async def remove_doubao(
image: UploadFile = File(...),
mode: str = Form(inpainting_service.DEFAULT_MODE),
) -> Response:
try:
image_bytes = await image.read()
pil_image = Image.open(io.BytesIO(image_bytes))
result, normalized_mode, elapsed, metadata = run_remove_doubao(pil_image, mode=mode)
output = io.BytesIO()
result.save(output, format="PNG")
return Response(
content=output.getvalue(),
media_type="image/png",
headers={
"X-Inpaint-Mode": normalized_mode,
"X-Inpaint-Backend": inpainting_service.MODES[normalized_mode],
"X-Inpaint-Elapsed": f"{elapsed:.3f}",
"X-Doubao-Confidence": str(metadata["confidence"]),
"X-Doubao-Coverage": str(metadata["coverage"]),
"X-Doubao-Detection": str(metadata["detection"]),
"X-Doubao-BBox": ",".join(str(v) for v in metadata["bbox"]),
},
)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.api_route(
"/sub2api",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def sub2api_console() -> RedirectResponse:
return RedirectResponse(url="/login", status_code=302)
@app.api_route(
"/{path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def sub2api_console_assets(request: Request, path: str) -> StreamingResponse:
return await _proxy_sub2api(request, f"/{path}")