huanx commited on
Commit
8ee6d52
·
verified ·
1 Parent(s): f6fb4e1

Deploy public web upscaler

Browse files
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/admin`.
 
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
- @app.get("/", response_class=JSONResponse)
222
- async def root() -> JSONResponse:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if file is None:
425
- raise HTTPException(status_code=400, detail="Missing image file.")
426
- settings = typed_runtime_settings()
427
- chosen_scale = scale or settings["default_scale"]
428
- chosen_variant = variant or settings["default_variant"]
429
- chosen_alpha = alpha if alpha is not None else settings["default_alpha"]
430
- chosen_tile = tile_mode if tile_mode is not None else settings["default_tile_mode"]
431
- chosen_cache = cache_mode if cache_mode is not None else settings["default_cache_mode"]
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 %}