Spaces:
Sleeping
Sleeping
Deploy public web upscaler
Browse files- README.md +3 -1
- app/main.py +145 -96
- app/static/public.js +197 -0
- app/static/styles.css +292 -0
- app/templates/base.html +2 -0
- app/templates/public.html +142 -0
README.md
CHANGED
|
@@ -11,6 +11,7 @@ app_port: 7860
|
|
| 11 |
|
| 12 |
CPU-first Hugging Face Space for anime image upscaling with:
|
| 13 |
|
|
|
|
| 14 |
- API key protected `/api/v1/upscale`
|
| 15 |
- Admin login, key management, runtime controls
|
| 16 |
- Job history and lightweight dashboard
|
|
@@ -83,7 +84,8 @@ export ADMIN_PASSWORD='change-me-now'
|
|
| 83 |
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 84 |
```
|
| 85 |
|
| 86 |
-
Open `http://127.0.0.1:7860/
|
|
|
|
| 87 |
|
| 88 |
## Notes
|
| 89 |
|
|
|
|
| 11 |
|
| 12 |
CPU-first Hugging Face Space for anime image upscaling with:
|
| 13 |
|
| 14 |
+
- Beginner-friendly browser web UI at `/`
|
| 15 |
- API key protected `/api/v1/upscale`
|
| 16 |
- Admin login, key management, runtime controls
|
| 17 |
- Job history and lightweight dashboard
|
|
|
|
| 84 |
uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 85 |
```
|
| 86 |
|
| 87 |
+
Open `http://127.0.0.1:7860/` for the public web upscaler.
|
| 88 |
+
Open `http://127.0.0.1:7860/admin` for the admin console.
|
| 89 |
|
| 90 |
## Notes
|
| 91 |
|
app/main.py
CHANGED
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Any
|
| 6 |
-
from uuid import uuid4
|
| 7 |
|
| 8 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 9 |
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
@@ -23,11 +22,8 @@ db.initialize()
|
|
| 23 |
runtime = RealCuganRuntime(env)
|
| 24 |
|
| 25 |
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
| 26 |
-
outputs_dir = Path("/tmp/realcugan_outputs")
|
| 27 |
-
outputs_dir.mkdir(parents=True, exist_ok=True)
|
| 28 |
|
| 29 |
app = FastAPI(title=env.app_name, docs_url=None, redoc_url=None)
|
| 30 |
-
app.mount("/outputs", StaticFiles(directory=str(outputs_dir)), name="outputs")
|
| 31 |
app.add_middleware(
|
| 32 |
SessionMiddleware,
|
| 33 |
secret_key=env.session_secret,
|
|
@@ -218,8 +214,122 @@ def dashboard_context(request: Request, *, issued_key: str | None = None) -> dic
|
|
| 218 |
}
|
| 219 |
|
| 220 |
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
return JSONResponse(
|
| 224 |
{
|
| 225 |
"name": env.app_name,
|
|
@@ -409,6 +519,27 @@ async def admin_metrics(request: Request) -> JSONResponse:
|
|
| 409 |
return JSONResponse(payload)
|
| 410 |
|
| 411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
@app.post("/api/v1/upscale")
|
| 413 |
async def upscale_image(
|
| 414 |
request: Request,
|
|
@@ -421,94 +552,12 @@ async def upscale_image(
|
|
| 421 |
response_format: str = Form("png"),
|
| 422 |
) -> Response:
|
| 423 |
authenticate_api_request(request)
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
chosen_format = response_format.strip().lower()
|
| 433 |
-
if chosen_format not in {"png", "jpeg", "webp"}:
|
| 434 |
-
raise HTTPException(status_code=400, detail="Unsupported response_format.")
|
| 435 |
-
payload = await file.read()
|
| 436 |
-
if not payload:
|
| 437 |
-
raise HTTPException(status_code=400, detail="Empty upload.")
|
| 438 |
-
try:
|
| 439 |
-
prepared = runtime.inspect_input(
|
| 440 |
-
payload,
|
| 441 |
-
max_input_long_edge=settings["max_input_long_edge"],
|
| 442 |
-
max_input_pixels=settings["max_input_pixels"],
|
| 443 |
-
)
|
| 444 |
-
except ValueError as exc:
|
| 445 |
-
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 446 |
-
job_id = db.create_job(
|
| 447 |
-
status="queued",
|
| 448 |
-
source_filename=file.filename or "upload",
|
| 449 |
-
input_sha256=prepared.input_sha256,
|
| 450 |
-
input_width=prepared.width,
|
| 451 |
-
input_height=prepared.height,
|
| 452 |
-
scale=chosen_scale,
|
| 453 |
-
model_variant=chosen_variant,
|
| 454 |
-
alpha=chosen_alpha,
|
| 455 |
-
tile_mode_requested=chosen_tile,
|
| 456 |
-
cache_mode=chosen_cache,
|
| 457 |
-
response_format=chosen_format,
|
| 458 |
)
|
| 459 |
-
db.mark_job_running(job_id)
|
| 460 |
-
try:
|
| 461 |
-
result = runtime.upscale(
|
| 462 |
-
prepared,
|
| 463 |
-
scale=chosen_scale,
|
| 464 |
-
variant=chosen_variant,
|
| 465 |
-
alpha=chosen_alpha,
|
| 466 |
-
tile_mode=chosen_tile,
|
| 467 |
-
cache_mode=chosen_cache,
|
| 468 |
-
response_format=chosen_format,
|
| 469 |
-
jpeg_quality=settings["jpeg_quality"],
|
| 470 |
-
)
|
| 471 |
-
except BusyError as exc:
|
| 472 |
-
db.mark_job_failed(job_id, str(exc))
|
| 473 |
-
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
| 474 |
-
except (RuntimeConfigurationError, UnsupportedModelError) as exc:
|
| 475 |
-
db.mark_job_failed(job_id, str(exc))
|
| 476 |
-
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 477 |
-
except Exception as exc: # noqa: BLE001
|
| 478 |
-
db.mark_job_failed(job_id, str(exc))
|
| 479 |
-
raise HTTPException(status_code=500, detail="Upscaling failed.") from exc
|
| 480 |
-
db.mark_job_success(
|
| 481 |
-
job_id,
|
| 482 |
-
output_width=result.output_width,
|
| 483 |
-
output_height=result.output_height,
|
| 484 |
-
tile_mode_used=result.tile_mode_used,
|
| 485 |
-
duration_ms=result.duration_ms,
|
| 486 |
-
)
|
| 487 |
-
headers = {
|
| 488 |
-
"X-Job-Id": str(job_id),
|
| 489 |
-
"X-Scale": str(result.scale),
|
| 490 |
-
"X-Variant": result.variant,
|
| 491 |
-
"X-Tile-Mode-Used": str(result.tile_mode_used),
|
| 492 |
-
"X-Cache-Mode-Used": str(result.cache_mode_used),
|
| 493 |
-
"X-Duration-Ms": str(result.duration_ms),
|
| 494 |
-
}
|
| 495 |
-
if request.headers.get("x-return-url", "").lower() in {"1", "true", "yes"}:
|
| 496 |
-
ext = {"png": "png", "jpeg": "jpg", "webp": "webp"}[chosen_format]
|
| 497 |
-
filename = f"rc_{job_id}_{uuid4().hex}.{ext}"
|
| 498 |
-
output_path = outputs_dir / filename
|
| 499 |
-
output_path.write_bytes(result.content)
|
| 500 |
-
url = str(request.base_url).rstrip("/") + f"/outputs/{filename}"
|
| 501 |
-
headers["X-Output-Url"] = url
|
| 502 |
-
return JSONResponse(
|
| 503 |
-
{
|
| 504 |
-
"url": url,
|
| 505 |
-
"filename": filename,
|
| 506 |
-
"size": len(result.content),
|
| 507 |
-
"media_type": result.media_type,
|
| 508 |
-
"width": result.output_width,
|
| 509 |
-
"height": result.output_height,
|
| 510 |
-
"job_id": job_id,
|
| 511 |
-
},
|
| 512 |
-
headers=headers,
|
| 513 |
-
)
|
| 514 |
-
return Response(content=result.content, media_type=result.media_type, headers=headers)
|
|
|
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
from pathlib import Path
|
| 5 |
from typing import Any
|
|
|
|
| 6 |
|
| 7 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 8 |
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
|
|
|
| 22 |
runtime = RealCuganRuntime(env)
|
| 23 |
|
| 24 |
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
|
|
|
|
|
|
|
| 25 |
|
| 26 |
app = FastAPI(title=env.app_name, docs_url=None, redoc_url=None)
|
|
|
|
| 27 |
app.add_middleware(
|
| 28 |
SessionMiddleware,
|
| 29 |
secret_key=env.session_secret,
|
|
|
|
| 214 |
}
|
| 215 |
|
| 216 |
|
| 217 |
+
def public_context(request: Request) -> dict[str, Any]:
|
| 218 |
+
settings = get_runtime_settings()
|
| 219 |
+
model_options = {
|
| 220 |
+
scale: [
|
| 221 |
+
{
|
| 222 |
+
"value": variant,
|
| 223 |
+
"label": variant,
|
| 224 |
+
"available": any((env.weights_dir / filename).exists() for filename in candidates),
|
| 225 |
+
}
|
| 226 |
+
for variant, candidates in variants.items()
|
| 227 |
+
]
|
| 228 |
+
for scale, variants in MODEL_CATALOG.items()
|
| 229 |
+
}
|
| 230 |
+
return {
|
| 231 |
+
"request": request,
|
| 232 |
+
"app_name": env.app_name,
|
| 233 |
+
"csrf_token": csrf_token(request),
|
| 234 |
+
"settings": settings,
|
| 235 |
+
"model_options": model_options,
|
| 236 |
+
"runtime_status": runtime.runtime_status(),
|
| 237 |
+
"max_input_long_edge": settings["max_input_long_edge"],
|
| 238 |
+
"max_input_pixels": settings["max_input_pixels"],
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
async def run_upscale_job(
|
| 243 |
+
*,
|
| 244 |
+
file: UploadFile | None,
|
| 245 |
+
scale: int | None,
|
| 246 |
+
variant: str | None,
|
| 247 |
+
alpha: float | None,
|
| 248 |
+
tile_mode: int | None,
|
| 249 |
+
cache_mode: int | None,
|
| 250 |
+
response_format: str,
|
| 251 |
+
) -> Response:
|
| 252 |
+
if file is None:
|
| 253 |
+
raise HTTPException(status_code=400, detail="Missing image file.")
|
| 254 |
+
settings = typed_runtime_settings()
|
| 255 |
+
chosen_scale = scale or settings["default_scale"]
|
| 256 |
+
chosen_variant = variant or settings["default_variant"]
|
| 257 |
+
chosen_alpha = alpha if alpha is not None else settings["default_alpha"]
|
| 258 |
+
chosen_tile = tile_mode if tile_mode is not None else settings["default_tile_mode"]
|
| 259 |
+
chosen_cache = cache_mode if cache_mode is not None else settings["default_cache_mode"]
|
| 260 |
+
chosen_format = response_format.strip().lower()
|
| 261 |
+
if chosen_format not in {"png", "jpeg", "webp"}:
|
| 262 |
+
raise HTTPException(status_code=400, detail="Unsupported response_format.")
|
| 263 |
+
payload = await file.read()
|
| 264 |
+
if not payload:
|
| 265 |
+
raise HTTPException(status_code=400, detail="Empty upload.")
|
| 266 |
+
try:
|
| 267 |
+
prepared = runtime.inspect_input(
|
| 268 |
+
payload,
|
| 269 |
+
max_input_long_edge=settings["max_input_long_edge"],
|
| 270 |
+
max_input_pixels=settings["max_input_pixels"],
|
| 271 |
+
)
|
| 272 |
+
except ValueError as exc:
|
| 273 |
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 274 |
+
job_id = db.create_job(
|
| 275 |
+
status="queued",
|
| 276 |
+
source_filename=file.filename or "upload",
|
| 277 |
+
input_sha256=prepared.input_sha256,
|
| 278 |
+
input_width=prepared.width,
|
| 279 |
+
input_height=prepared.height,
|
| 280 |
+
scale=chosen_scale,
|
| 281 |
+
model_variant=chosen_variant,
|
| 282 |
+
alpha=chosen_alpha,
|
| 283 |
+
tile_mode_requested=chosen_tile,
|
| 284 |
+
cache_mode=chosen_cache,
|
| 285 |
+
response_format=chosen_format,
|
| 286 |
+
)
|
| 287 |
+
db.mark_job_running(job_id)
|
| 288 |
+
try:
|
| 289 |
+
result = runtime.upscale(
|
| 290 |
+
prepared,
|
| 291 |
+
scale=chosen_scale,
|
| 292 |
+
variant=chosen_variant,
|
| 293 |
+
alpha=chosen_alpha,
|
| 294 |
+
tile_mode=chosen_tile,
|
| 295 |
+
cache_mode=chosen_cache,
|
| 296 |
+
response_format=chosen_format,
|
| 297 |
+
jpeg_quality=settings["jpeg_quality"],
|
| 298 |
+
)
|
| 299 |
+
except BusyError as exc:
|
| 300 |
+
db.mark_job_failed(job_id, str(exc))
|
| 301 |
+
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
| 302 |
+
except (RuntimeConfigurationError, UnsupportedModelError) as exc:
|
| 303 |
+
db.mark_job_failed(job_id, str(exc))
|
| 304 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 305 |
+
except Exception as exc: # noqa: BLE001
|
| 306 |
+
db.mark_job_failed(job_id, str(exc))
|
| 307 |
+
raise HTTPException(status_code=500, detail="Upscaling failed.") from exc
|
| 308 |
+
db.mark_job_success(
|
| 309 |
+
job_id,
|
| 310 |
+
output_width=result.output_width,
|
| 311 |
+
output_height=result.output_height,
|
| 312 |
+
tile_mode_used=result.tile_mode_used,
|
| 313 |
+
duration_ms=result.duration_ms,
|
| 314 |
+
)
|
| 315 |
+
headers = {
|
| 316 |
+
"X-Job-Id": str(job_id),
|
| 317 |
+
"X-Scale": str(result.scale),
|
| 318 |
+
"X-Variant": result.variant,
|
| 319 |
+
"X-Tile-Mode-Used": str(result.tile_mode_used),
|
| 320 |
+
"X-Cache-Mode-Used": str(result.cache_mode_used),
|
| 321 |
+
"X-Duration-Ms": str(result.duration_ms),
|
| 322 |
+
}
|
| 323 |
+
return Response(content=result.content, media_type=result.media_type, headers=headers)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
@app.get("/", response_class=HTMLResponse)
|
| 327 |
+
async def public_home(request: Request) -> HTMLResponse:
|
| 328 |
+
return templates.TemplateResponse(request, "public.html", public_context(request))
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@app.get("/api", response_class=JSONResponse)
|
| 332 |
+
async def api_info() -> JSONResponse:
|
| 333 |
return JSONResponse(
|
| 334 |
{
|
| 335 |
"name": env.app_name,
|
|
|
|
| 519 |
return JSONResponse(payload)
|
| 520 |
|
| 521 |
|
| 522 |
+
@app.post("/upscale")
|
| 523 |
+
async def public_upscale(
|
| 524 |
+
request: Request,
|
| 525 |
+
file: UploadFile | None = File(None),
|
| 526 |
+
scale: int | None = Form(None),
|
| 527 |
+
variant: str | None = Form(None),
|
| 528 |
+
response_format: str = Form("png"),
|
| 529 |
+
csrf: str = Form(...),
|
| 530 |
+
) -> Response:
|
| 531 |
+
ensure_csrf(request, csrf)
|
| 532 |
+
return await run_upscale_job(
|
| 533 |
+
file=file,
|
| 534 |
+
scale=scale,
|
| 535 |
+
variant=variant,
|
| 536 |
+
alpha=None,
|
| 537 |
+
tile_mode=3,
|
| 538 |
+
cache_mode=1,
|
| 539 |
+
response_format=response_format,
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
|
| 543 |
@app.post("/api/v1/upscale")
|
| 544 |
async def upscale_image(
|
| 545 |
request: Request,
|
|
|
|
| 552 |
response_format: str = Form("png"),
|
| 553 |
) -> Response:
|
| 554 |
authenticate_api_request(request)
|
| 555 |
+
return await run_upscale_job(
|
| 556 |
+
file=file,
|
| 557 |
+
scale=scale,
|
| 558 |
+
variant=variant,
|
| 559 |
+
alpha=alpha,
|
| 560 |
+
tile_mode=tile_mode,
|
| 561 |
+
cache_mode=cache_mode,
|
| 562 |
+
response_format=response_format,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/static/public.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const form = document.querySelector("#upscale-form");
|
| 2 |
+
const fileInput = document.querySelector("#file");
|
| 3 |
+
const dropZone = document.querySelector("[data-drop-zone]");
|
| 4 |
+
const inputPreview = document.querySelector("[data-input-preview]");
|
| 5 |
+
const outputPreview = document.querySelector("[data-output-preview]");
|
| 6 |
+
const resultPlaceholder = document.querySelector("[data-result-placeholder]");
|
| 7 |
+
const downloadRow = document.querySelector("[data-download-row]");
|
| 8 |
+
const downloadLink = document.querySelector("[data-download-link]");
|
| 9 |
+
const jobMeta = document.querySelector("[data-job-meta]");
|
| 10 |
+
const statusNode = document.querySelector("[data-status]");
|
| 11 |
+
const submitButton = document.querySelector("[data-submit-button]");
|
| 12 |
+
|
| 13 |
+
let inputPreviewUrl = "";
|
| 14 |
+
let outputPreviewUrl = "";
|
| 15 |
+
let startedAt = 0;
|
| 16 |
+
let timer = 0;
|
| 17 |
+
|
| 18 |
+
function setStatus(message, tone = "") {
|
| 19 |
+
if (!statusNode) return;
|
| 20 |
+
statusNode.textContent = message;
|
| 21 |
+
statusNode.dataset.tone = tone;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function friendlyError(detail, status) {
|
| 25 |
+
const text = String(detail || "");
|
| 26 |
+
if (status === 429 || text.includes("Another inference")) {
|
| 27 |
+
return "现在有另一张图正在处理,请稍等一会再试。";
|
| 28 |
+
}
|
| 29 |
+
if (status === 503 || text.includes("Weight for") || text.includes("Unsupported model")) {
|
| 30 |
+
return "这个模型组合暂时不可用。请先试 2x + no-denoise。";
|
| 31 |
+
}
|
| 32 |
+
if (text.includes("too large") || text.includes("exceeds")) {
|
| 33 |
+
return "这张图片超过当前 CPU 演示限制,请先缩小一点再上传。";
|
| 34 |
+
}
|
| 35 |
+
if (text.includes("too small")) {
|
| 36 |
+
return "图片太小了,最短边至少需要 8px。";
|
| 37 |
+
}
|
| 38 |
+
if (text.includes("Unsupported") || text.includes("corrupted")) {
|
| 39 |
+
return "图片格式不支持或文件损坏,请换一张常见格式图片。";
|
| 40 |
+
}
|
| 41 |
+
if (text.includes("Missing") || text.includes("Empty")) {
|
| 42 |
+
return "请先选择一张图片。";
|
| 43 |
+
}
|
| 44 |
+
return text || "处理失败了,请换张图或稍后再试。";
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function showInputPreview(file) {
|
| 48 |
+
if (!inputPreview || !file) return;
|
| 49 |
+
if (inputPreviewUrl) URL.revokeObjectURL(inputPreviewUrl);
|
| 50 |
+
inputPreviewUrl = URL.createObjectURL(file);
|
| 51 |
+
inputPreview.src = inputPreviewUrl;
|
| 52 |
+
inputPreview.hidden = false;
|
| 53 |
+
setStatus(`已选择:${file.name},可以开始放大。`);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function resetResult() {
|
| 57 |
+
if (outputPreviewUrl) URL.revokeObjectURL(outputPreviewUrl);
|
| 58 |
+
outputPreviewUrl = "";
|
| 59 |
+
if (outputPreview) {
|
| 60 |
+
outputPreview.removeAttribute("src");
|
| 61 |
+
outputPreview.hidden = true;
|
| 62 |
+
}
|
| 63 |
+
if (resultPlaceholder) resultPlaceholder.hidden = false;
|
| 64 |
+
if (downloadRow) downloadRow.hidden = true;
|
| 65 |
+
if (downloadLink) downloadLink.removeAttribute("href");
|
| 66 |
+
if (jobMeta) jobMeta.textContent = "";
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function startTimer() {
|
| 70 |
+
startedAt = Date.now();
|
| 71 |
+
window.clearInterval(timer);
|
| 72 |
+
timer = window.setInterval(() => {
|
| 73 |
+
const seconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
|
| 74 |
+
setStatus(`处理中……CPU 推理比较慢,已经等待 ${seconds} 秒。`);
|
| 75 |
+
}, 1000);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function stopTimer() {
|
| 79 |
+
window.clearInterval(timer);
|
| 80 |
+
timer = 0;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
async function parseError(response) {
|
| 84 |
+
try {
|
| 85 |
+
const payload = await response.json();
|
| 86 |
+
return payload.detail || response.statusText;
|
| 87 |
+
} catch (_) {
|
| 88 |
+
return response.statusText;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function syncVariantOptions() {
|
| 93 |
+
if (!form) return;
|
| 94 |
+
const scale = form.querySelector('input[name="scale"]:checked')?.value || "2";
|
| 95 |
+
const select = form.querySelector('select[name="variant"]');
|
| 96 |
+
if (!select) return;
|
| 97 |
+
let selectedStillValid = false;
|
| 98 |
+
Array.from(select.options).forEach((option) => {
|
| 99 |
+
const scales = (option.dataset.scales || "").split(",");
|
| 100 |
+
const valid = scales.includes(scale);
|
| 101 |
+
option.disabled = !valid;
|
| 102 |
+
if (option.selected && valid) selectedStillValid = true;
|
| 103 |
+
});
|
| 104 |
+
if (!selectedStillValid) {
|
| 105 |
+
const fallback = Array.from(select.options).find((option) => !option.disabled);
|
| 106 |
+
if (fallback) fallback.selected = true;
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
function bindDropZone() {
|
| 110 |
+
if (!dropZone || !fileInput) return;
|
| 111 |
+
["dragenter", "dragover"].forEach((eventName) => {
|
| 112 |
+
dropZone.addEventListener(eventName, (event) => {
|
| 113 |
+
event.preventDefault();
|
| 114 |
+
dropZone.classList.add("drop-zone--active");
|
| 115 |
+
});
|
| 116 |
+
});
|
| 117 |
+
["dragleave", "drop"].forEach((eventName) => {
|
| 118 |
+
dropZone.addEventListener(eventName, (event) => {
|
| 119 |
+
event.preventDefault();
|
| 120 |
+
dropZone.classList.remove("drop-zone--active");
|
| 121 |
+
});
|
| 122 |
+
});
|
| 123 |
+
dropZone.addEventListener("drop", (event) => {
|
| 124 |
+
const file = event.dataTransfer?.files?.[0];
|
| 125 |
+
if (!file) return;
|
| 126 |
+
fileInput.files = event.dataTransfer.files;
|
| 127 |
+
resetResult();
|
| 128 |
+
showInputPreview(file);
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
fileInput?.addEventListener("change", () => {
|
| 133 |
+
const file = fileInput.files?.[0];
|
| 134 |
+
resetResult();
|
| 135 |
+
if (file) showInputPreview(file);
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
form?.addEventListener("submit", async (event) => {
|
| 139 |
+
event.preventDefault();
|
| 140 |
+
const file = fileInput?.files?.[0];
|
| 141 |
+
if (!file) {
|
| 142 |
+
setStatus("请先选择一张图片。", "error");
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
+
if (!file.type.startsWith("image/")) {
|
| 146 |
+
setStatus("请选择图片文件,不要上传压缩包或其他文件。", "error");
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
resetResult();
|
| 151 |
+
submitButton?.setAttribute("disabled", "disabled");
|
| 152 |
+
startTimer();
|
| 153 |
+
|
| 154 |
+
try {
|
| 155 |
+
const response = await fetch(form.action, {
|
| 156 |
+
method: "POST",
|
| 157 |
+
body: new FormData(form),
|
| 158 |
+
credentials: "same-origin",
|
| 159 |
+
});
|
| 160 |
+
if (!response.ok) {
|
| 161 |
+
const detail = await parseError(response);
|
| 162 |
+
throw new Error(friendlyError(detail, response.status));
|
| 163 |
+
}
|
| 164 |
+
const blob = await response.blob();
|
| 165 |
+
outputPreviewUrl = URL.createObjectURL(blob);
|
| 166 |
+
if (outputPreview) {
|
| 167 |
+
outputPreview.src = outputPreviewUrl;
|
| 168 |
+
outputPreview.hidden = false;
|
| 169 |
+
}
|
| 170 |
+
if (resultPlaceholder) resultPlaceholder.hidden = true;
|
| 171 |
+
const format = form.querySelector('input[name="response_format"]:checked')?.value || "png";
|
| 172 |
+
const scale = response.headers.get("x-scale") || form.querySelector('input[name="scale"]:checked')?.value || "2";
|
| 173 |
+
if (downloadLink) {
|
| 174 |
+
downloadLink.href = outputPreviewUrl;
|
| 175 |
+
downloadLink.download = `real-cugan-${scale}x.${format === "jpeg" ? "jpg" : format}`;
|
| 176 |
+
}
|
| 177 |
+
if (downloadRow) downloadRow.hidden = false;
|
| 178 |
+
if (jobMeta) {
|
| 179 |
+
const jobId = response.headers.get("x-job-id") || "-";
|
| 180 |
+
const duration = response.headers.get("x-duration-ms") || "-";
|
| 181 |
+
const variant = response.headers.get("x-variant") || "-";
|
| 182 |
+
jobMeta.textContent = `job #${jobId} · ${scale}x/${variant} · ${duration} ms`;
|
| 183 |
+
}
|
| 184 |
+
setStatus("完成!可以预览或下载结果。", "success");
|
| 185 |
+
} catch (error) {
|
| 186 |
+
setStatus(error.message || "处理失败了,请稍后再试。", "error");
|
| 187 |
+
} finally {
|
| 188 |
+
stopTimer();
|
| 189 |
+
submitButton?.removeAttribute("disabled");
|
| 190 |
+
}
|
| 191 |
+
});
|
| 192 |
+
|
| 193 |
+
bindDropZone();
|
| 194 |
+
syncVariantOptions();
|
| 195 |
+
form?.querySelectorAll('input[name="scale"]').forEach((input) => {
|
| 196 |
+
input.addEventListener("change", syncVariantOptions);
|
| 197 |
+
});
|
app/static/styles.css
CHANGED
|
@@ -661,3 +661,295 @@ input:disabled {
|
|
| 661 |
scroll-behavior: auto !important;
|
| 662 |
}
|
| 663 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
scroll-behavior: auto !important;
|
| 662 |
}
|
| 663 |
}
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
.public-shell {
|
| 667 |
+
width: min(calc(100% - 32px), var(--content-width));
|
| 668 |
+
margin: 0 auto;
|
| 669 |
+
padding: 28px 0 36px;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.public-hero {
|
| 673 |
+
display: grid;
|
| 674 |
+
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
| 675 |
+
gap: 20px;
|
| 676 |
+
align-items: stretch;
|
| 677 |
+
margin-bottom: 20px;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
.public-hero__copy {
|
| 681 |
+
position: relative;
|
| 682 |
+
overflow: hidden;
|
| 683 |
+
padding: clamp(24px, 4vw, 44px);
|
| 684 |
+
border: 1px solid rgba(121, 93, 48, 0.12);
|
| 685 |
+
border-radius: var(--radius);
|
| 686 |
+
background:
|
| 687 |
+
radial-gradient(circle at right 12% top 10%, rgba(222, 174, 58, 0.22), transparent 26%),
|
| 688 |
+
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(249, 242, 230, 0.94));
|
| 689 |
+
box-shadow: var(--shadow);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.public-hero h1 {
|
| 693 |
+
margin: 0;
|
| 694 |
+
max-width: 13ch;
|
| 695 |
+
font-size: clamp(2.6rem, 7vw, 6.4rem);
|
| 696 |
+
line-height: 0.92;
|
| 697 |
+
letter-spacing: -0.07em;
|
| 698 |
+
text-wrap: balance;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
.hero-copy {
|
| 702 |
+
max-width: 740px;
|
| 703 |
+
color: var(--muted);
|
| 704 |
+
font-size: clamp(1rem, 1.4vw, 1.2rem);
|
| 705 |
+
line-height: 1.75;
|
| 706 |
+
text-wrap: pretty;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.hero-actions,
|
| 710 |
+
.download-row {
|
| 711 |
+
display: flex;
|
| 712 |
+
flex-wrap: wrap;
|
| 713 |
+
gap: 12px;
|
| 714 |
+
align-items: center;
|
| 715 |
+
margin-top: 18px;
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
.hero-guide {
|
| 719 |
+
display: grid;
|
| 720 |
+
align-content: center;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.step-list {
|
| 724 |
+
display: grid;
|
| 725 |
+
gap: 14px;
|
| 726 |
+
padding: 0;
|
| 727 |
+
margin: 0;
|
| 728 |
+
list-style: none;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.step-list li {
|
| 732 |
+
display: grid;
|
| 733 |
+
grid-template-columns: auto 1fr;
|
| 734 |
+
gap: 6px 12px;
|
| 735 |
+
padding: 14px;
|
| 736 |
+
border: 1px solid rgba(126, 96, 47, 0.08);
|
| 737 |
+
border-radius: 14px;
|
| 738 |
+
background: rgba(255, 252, 247, 0.78);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.step-list strong {
|
| 742 |
+
color: oklch(0.43 0.1 80);
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
.step-list span {
|
| 746 |
+
color: var(--muted);
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.public-grid {
|
| 750 |
+
display: grid;
|
| 751 |
+
grid-template-columns: minmax(0, 1fr) minmax(340px, 0.72fr);
|
| 752 |
+
gap: 18px;
|
| 753 |
+
align-items: start;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.uploader-card,
|
| 757 |
+
.result-column {
|
| 758 |
+
display: grid;
|
| 759 |
+
gap: 18px;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.drop-zone {
|
| 763 |
+
display: grid;
|
| 764 |
+
justify-items: center;
|
| 765 |
+
gap: 10px;
|
| 766 |
+
padding: clamp(22px, 4vw, 42px);
|
| 767 |
+
border: 2px dashed rgba(178, 125, 21, 0.34);
|
| 768 |
+
border-radius: 22px;
|
| 769 |
+
background: rgba(255, 252, 246, 0.7);
|
| 770 |
+
text-align: center;
|
| 771 |
+
cursor: pointer;
|
| 772 |
+
transition: border-color 160ms cubic-bezier(0.2, 0, 0, 1), background-color 160ms cubic-bezier(0.2, 0, 0, 1), transform 160ms cubic-bezier(0.2, 0, 0, 1);
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.drop-zone input {
|
| 776 |
+
position: absolute;
|
| 777 |
+
width: 1px;
|
| 778 |
+
height: 1px;
|
| 779 |
+
overflow: hidden;
|
| 780 |
+
clip: rect(0, 0, 0, 0);
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
.drop-zone__icon {
|
| 784 |
+
font-size: 2.6rem;
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
.drop-zone small,
|
| 788 |
+
.helper-text,
|
| 789 |
+
.progress-note {
|
| 790 |
+
color: var(--muted);
|
| 791 |
+
line-height: 1.55;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.drop-zone--active {
|
| 795 |
+
border-color: rgba(178, 125, 21, 0.72);
|
| 796 |
+
background: rgba(255, 245, 220, 0.9);
|
| 797 |
+
transform: translateY(-1px);
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.option-section {
|
| 801 |
+
display: grid;
|
| 802 |
+
gap: 12px;
|
| 803 |
+
margin-top: 18px;
|
| 804 |
+
}
|
| 805 |
+
|
| 806 |
+
.option-section h3 {
|
| 807 |
+
margin: 0;
|
| 808 |
+
font-size: 1rem;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.option-grid {
|
| 812 |
+
display: grid;
|
| 813 |
+
gap: 10px;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.option-grid--three {
|
| 817 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.option-card {
|
| 821 |
+
display: grid;
|
| 822 |
+
gap: 7px;
|
| 823 |
+
padding: 14px;
|
| 824 |
+
border: 1px solid rgba(126, 96, 47, 0.12);
|
| 825 |
+
border-radius: 14px;
|
| 826 |
+
background: rgba(255, 255, 255, 0.68);
|
| 827 |
+
cursor: pointer;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
.option-card input {
|
| 831 |
+
width: auto;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
.option-card span {
|
| 835 |
+
font-weight: 800;
|
| 836 |
+
}
|
| 837 |
+
|
| 838 |
+
.option-card small {
|
| 839 |
+
color: var(--muted);
|
| 840 |
+
line-height: 1.35;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
.submit-button {
|
| 844 |
+
width: 100%;
|
| 845 |
+
margin-top: 18px;
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
.submit-button:disabled {
|
| 849 |
+
cursor: wait;
|
| 850 |
+
opacity: 0.7;
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.progress-note {
|
| 854 |
+
margin: 0;
|
| 855 |
+
padding: 12px 14px;
|
| 856 |
+
border-radius: 14px;
|
| 857 |
+
background: rgba(255, 252, 247, 0.78);
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
.progress-note[data-tone="success"] {
|
| 861 |
+
color: oklch(0.35 0.09 150);
|
| 862 |
+
background: var(--green-soft);
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
.progress-note[data-tone="error"] {
|
| 866 |
+
color: oklch(0.42 0.13 24);
|
| 867 |
+
background: var(--red-soft);
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.preview-grid {
|
| 871 |
+
display: grid;
|
| 872 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 873 |
+
gap: 12px;
|
| 874 |
+
}
|
| 875 |
+
|
| 876 |
+
.preview-frame {
|
| 877 |
+
display: grid;
|
| 878 |
+
gap: 10px;
|
| 879 |
+
margin: 0;
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
.preview-frame img,
|
| 883 |
+
.placeholder {
|
| 884 |
+
width: 100%;
|
| 885 |
+
min-height: 220px;
|
| 886 |
+
max-height: 420px;
|
| 887 |
+
object-fit: contain;
|
| 888 |
+
border: 1px solid rgba(126, 96, 47, 0.1);
|
| 889 |
+
border-radius: 16px;
|
| 890 |
+
background:
|
| 891 |
+
linear-gradient(45deg, rgba(126, 96, 47, 0.05) 25%, transparent 25%),
|
| 892 |
+
linear-gradient(-45deg, rgba(126, 96, 47, 0.05) 25%, transparent 25%),
|
| 893 |
+
linear-gradient(45deg, transparent 75%, rgba(126, 96, 47, 0.05) 75%),
|
| 894 |
+
linear-gradient(-45deg, transparent 75%, rgba(126, 96, 47, 0.05) 75%);
|
| 895 |
+
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
| 896 |
+
background-size: 20px 20px;
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.placeholder {
|
| 900 |
+
display: grid;
|
| 901 |
+
place-items: center;
|
| 902 |
+
padding: 24px;
|
| 903 |
+
color: var(--muted);
|
| 904 |
+
text-align: center;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
.preview-frame figcaption,
|
| 908 |
+
.job-meta {
|
| 909 |
+
color: var(--muted);
|
| 910 |
+
font-size: 0.9rem;
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
.limit-list {
|
| 914 |
+
display: grid;
|
| 915 |
+
gap: 10px;
|
| 916 |
+
padding-left: 1.2rem;
|
| 917 |
+
color: var(--muted);
|
| 918 |
+
line-height: 1.6;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.limit-list a {
|
| 922 |
+
color: oklch(0.43 0.1 80);
|
| 923 |
+
font-weight: 700;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
@media (hover: hover) {
|
| 927 |
+
.drop-zone:hover,
|
| 928 |
+
.option-card:hover {
|
| 929 |
+
border-color: rgba(178, 125, 21, 0.4);
|
| 930 |
+
background: rgba(255, 255, 255, 0.92);
|
| 931 |
+
}
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
@media (max-width: 1080px) {
|
| 935 |
+
.public-hero,
|
| 936 |
+
.public-grid {
|
| 937 |
+
grid-template-columns: 1fr;
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
@media (max-width: 720px) {
|
| 942 |
+
.public-shell {
|
| 943 |
+
width: min(calc(100% - 20px), var(--content-width));
|
| 944 |
+
padding-top: 12px;
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
.option-grid--three,
|
| 948 |
+
.preview-grid {
|
| 949 |
+
grid-template-columns: 1fr;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
.public-hero h1 {
|
| 953 |
+
font-size: clamp(2.4rem, 17vw, 4.2rem);
|
| 954 |
+
}
|
| 955 |
+
}
|
app/templates/base.html
CHANGED
|
@@ -4,6 +4,8 @@
|
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
|
|
|
|
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
<link
|
|
|
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
| 7 |
+
<meta name="description" content="{% block description %}Real-CUGAN anime and illustration image upscaling service.{% endblock %}" />
|
| 8 |
+
<meta name="theme-color" content="#d3a12d" />
|
| 9 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 10 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 11 |
<link
|
app/templates/public.html
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}{{ app_name }} | Web Upscaler{% endblock %}
|
| 4 |
+
{% block description %}Upload an anime or illustration image and upscale it with Real-CUGAN directly in your browser.{% endblock %}
|
| 5 |
+
{% block head %}
|
| 6 |
+
<script defer src="{{ request.url_for('static', path='/public.js') }}"></script>
|
| 7 |
+
{% endblock %}
|
| 8 |
+
|
| 9 |
+
{% block body %}
|
| 10 |
+
<main class="public-shell">
|
| 11 |
+
<section class="public-hero">
|
| 12 |
+
<div class="public-hero__copy">
|
| 13 |
+
<p class="eyebrow">Real-CUGAN web upscaler</p>
|
| 14 |
+
<h1>把动漫图、插画图放大得更清楚。</h1>
|
| 15 |
+
<p class="hero-copy">
|
| 16 |
+
新手友好的网页入口:上传图片,选择倍数和风格,等待 CPU 慢慢处理,完成后直接下载结果。
|
| 17 |
+
图片只在内存里处理,不会默认保存原图。
|
| 18 |
+
</p>
|
| 19 |
+
<div class="hero-actions">
|
| 20 |
+
<a class="button button--primary" href="#upscale-form">开始上传</a>
|
| 21 |
+
<a class="button button--ghost" href="/admin">管理后台</a>
|
| 22 |
+
</div>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="hero-guide panel">
|
| 25 |
+
<p class="eyebrow">保姆级流程</p>
|
| 26 |
+
<ol class="step-list">
|
| 27 |
+
<li><strong>选图</strong><span>推荐 PNG/JPEG/WebP,长边不超过 {{ max_input_long_edge }}px。</span></li>
|
| 28 |
+
<li><strong>选参数</strong><span>不懂就保持默认:2x + no-denoise + PNG。</span></li>
|
| 29 |
+
<li><strong>等结果</strong><span>CPU 机器一次只跑一张,大图可能要等一会。</span></li>
|
| 30 |
+
</ol>
|
| 31 |
+
</div>
|
| 32 |
+
</section>
|
| 33 |
+
|
| 34 |
+
<section class="public-grid">
|
| 35 |
+
<form id="upscale-form" class="panel uploader-card" action="/upscale" method="post" enctype="multipart/form-data">
|
| 36 |
+
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
| 37 |
+
<div class="panel-heading">
|
| 38 |
+
<div>
|
| 39 |
+
<p class="eyebrow">Upload</p>
|
| 40 |
+
<h2>选择要放大的图片</h2>
|
| 41 |
+
</div>
|
| 42 |
+
<p class="panel-note">最大 {{ max_input_long_edge }}px / {{ max_input_pixels }} 像素</p>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<label class="drop-zone" data-drop-zone>
|
| 46 |
+
<input id="file" type="file" name="file" accept="image/png,image/jpeg,image/webp,image/bmp" required />
|
| 47 |
+
<span class="drop-zone__icon">🖼️</span>
|
| 48 |
+
<strong>点击选择图片,或拖到这里</strong>
|
| 49 |
+
<small>支持常见图片格式;透明 PNG 会尽量保留透明通道。</small>
|
| 50 |
+
</label>
|
| 51 |
+
|
| 52 |
+
<div class="option-section">
|
| 53 |
+
<h3>1. 放大倍数</h3>
|
| 54 |
+
<div class="option-grid option-grid--three">
|
| 55 |
+
{% for scale_value in [2, 3, 4] %}
|
| 56 |
+
<label class="option-card">
|
| 57 |
+
<input type="radio" name="scale" value="{{ scale_value }}" {% if settings.default_scale == scale_value|string %}checked{% endif %} />
|
| 58 |
+
<span>{{ scale_value }}x</span>
|
| 59 |
+
<small>{% if scale_value == 2 %}默认推荐,最稳{% elif scale_value == 3 %}更大,耗时更久{% else %}最大,CPU 压力最高{% endif %}</small>
|
| 60 |
+
</label>
|
| 61 |
+
{% endfor %}
|
| 62 |
+
</div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="option-section">
|
| 66 |
+
<h3>2. 模型风格</h3>
|
| 67 |
+
<select name="variant" aria-label="Model style">
|
| 68 |
+
<option value="no-denoise" data-scales="2,3,4" {% if settings.default_variant == "no-denoise" %}selected{% endif %}>No denoise:保留细节,默认推荐</option>
|
| 69 |
+
<option value="denoise1x" data-scales="2" {% if settings.default_variant == "denoise1x" %}selected{% endif %}>Denoise 1x:轻微去噪</option>
|
| 70 |
+
<option value="denoise2x" data-scales="2" {% if settings.default_variant == "denoise2x" %}selected{% endif %}>Denoise 2x:清理压缩痕迹</option>
|
| 71 |
+
<option value="denoise3x" data-scales="2,3,4" {% if settings.default_variant == "denoise3x" %}selected{% endif %}>Denoise 3x:更强去噪</option>
|
| 72 |
+
<option value="conservative" data-scales="2,3,4" {% if settings.default_variant == "conservative" %}selected{% endif %}>Conservative:保守柔和</option>
|
| 73 |
+
</select>
|
| 74 |
+
<p class="helper-text">提示:3x/4x 目前只支持 no-denoise、denoise3x、conservative;如果组合不支持,页面会提醒你。</p>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<div class="option-section">
|
| 78 |
+
<h3>3. 输出格式</h3>
|
| 79 |
+
<div class="option-grid option-grid--three">
|
| 80 |
+
<label class="option-card">
|
| 81 |
+
<input type="radio" name="response_format" value="png" checked />
|
| 82 |
+
<span>PNG</span>
|
| 83 |
+
<small>质量最好</small>
|
| 84 |
+
</label>
|
| 85 |
+
<label class="option-card">
|
| 86 |
+
<input type="radio" name="response_format" value="jpeg" />
|
| 87 |
+
<span>JPEG</span>
|
| 88 |
+
<small>文件较小</small>
|
| 89 |
+
</label>
|
| 90 |
+
<label class="option-card">
|
| 91 |
+
<input type="radio" name="response_format" value="webp" />
|
| 92 |
+
<span>WebP</span>
|
| 93 |
+
<small>网页友好</small>
|
| 94 |
+
</label>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<button class="button button--primary submit-button" type="submit" data-submit-button>
|
| 99 |
+
开始放大
|
| 100 |
+
</button>
|
| 101 |
+
<p class="progress-note" data-status>准备好了。选择图片后即可开始。</p>
|
| 102 |
+
</form>
|
| 103 |
+
|
| 104 |
+
<aside class="result-column">
|
| 105 |
+
<section class="panel preview-card">
|
| 106 |
+
<div class="panel-heading">
|
| 107 |
+
<div>
|
| 108 |
+
<p class="eyebrow">Preview</p>
|
| 109 |
+
<h2>预览和结果</h2>
|
| 110 |
+
</div>
|
| 111 |
+
</div>
|
| 112 |
+
<div class="preview-grid">
|
| 113 |
+
<figure class="preview-frame">
|
| 114 |
+
<img data-input-preview alt="Input preview" hidden />
|
| 115 |
+
<figcaption>原图预览</figcaption>
|
| 116 |
+
</figure>
|
| 117 |
+
<figure class="preview-frame preview-frame--result">
|
| 118 |
+
<img data-output-preview alt="Upscaled result" hidden />
|
| 119 |
+
<div class="placeholder" data-result-placeholder>结果会显示在这里</div>
|
| 120 |
+
<figcaption>放大结果</figcaption>
|
| 121 |
+
</figure>
|
| 122 |
+
</div>
|
| 123 |
+
<div class="download-row" data-download-row hidden>
|
| 124 |
+
<a class="button button--secondary" data-download-link href="#" download>下载结果</a>
|
| 125 |
+
<div class="job-meta mono" data-job-meta></div>
|
| 126 |
+
</div>
|
| 127 |
+
</section>
|
| 128 |
+
|
| 129 |
+
<section class="panel tips-card">
|
| 130 |
+
<p class="eyebrow">Tips</p>
|
| 131 |
+
<h2>新手建议</h2>
|
| 132 |
+
<ul class="limit-list">
|
| 133 |
+
<li>第一次建议用 <strong>2x + no-denoise + PNG</strong>。</li>
|
| 134 |
+
<li>如果原图噪点多,再试 denoise1x 或 denoise2x。</li>
|
| 135 |
+
<li>出现“另一个任务正在运行”时,等几十秒再点一次。</li>
|
| 136 |
+
<li>自动化调用请看 <a href="/api">API 信息</a>;后台在 <a href="/admin">/admin</a>。</li>
|
| 137 |
+
</ul>
|
| 138 |
+
</section>
|
| 139 |
+
</aside>
|
| 140 |
+
</section>
|
| 141 |
+
</main>
|
| 142 |
+
{% endblock %}
|