Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
1
services/enhance-worker/app/__init__.py
Normal file
1
services/enhance-worker/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package marker for the enhance worker app.
|
||||
BIN
services/enhance-worker/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
services/enhance-worker/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
services/enhance-worker/app/__pycache__/image_io.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/image_io.cpython-313.pyc
Normal file
Binary file not shown.
BIN
services/enhance-worker/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
services/enhance-worker/app/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
services/enhance-worker/app/__pycache__/security.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/security.cpython-313.pyc
Normal file
Binary file not shown.
BIN
services/enhance-worker/app/__pycache__/upscaler.cpython-313.pyc
Normal file
BIN
services/enhance-worker/app/__pycache__/upscaler.cpython-313.pyc
Normal file
Binary file not shown.
100
services/enhance-worker/app/config.py
Normal file
100
services/enhance-worker/app/config.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8095
|
||||
token: str = "change-this-token"
|
||||
engine: str = "pillow"
|
||||
device: str = "cpu"
|
||||
max_upload_mb: int = 20
|
||||
max_input_width: int = 4096
|
||||
max_input_height: int = 4096
|
||||
max_output_width: int = 8192
|
||||
max_output_height: int = 8192
|
||||
tmp_dir: str = "/app/storage/tmp"
|
||||
output_dir: str = "/app/storage/output"
|
||||
result_ttl_minutes: int = 60
|
||||
model_dir: str = "/app/app/models"
|
||||
default_model: str = "realesrgan-x4plus"
|
||||
realesrgan_bin: str = "/app/bin/realesrgan-ncnn-vulkan"
|
||||
realesrgan_model_dir: str = "/app/models"
|
||||
realesrgan_default_model: str = "realesrgan-x4plus"
|
||||
realesrgan_anime_model: str = "realesrgan-x4plus-anime"
|
||||
realesrgan_gpu_id: int = -1
|
||||
realesrgan_tile: int = 0
|
||||
realesrgan_tta: bool = False
|
||||
realesrgan_verbose: bool = False
|
||||
realesrgan_timeout_seconds: int = 900
|
||||
realesrgan_preprocess_max_pixels: int = 16_777_216
|
||||
realesrgan_output_ext: str = "webp"
|
||||
realesrgan_allow_model_fallback: bool = True
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)).strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
normalized = value.strip().lower()
|
||||
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
|
||||
return default
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
legacy_model_dir = os.getenv("WORKER_MODEL_DIR", "/app/app/models").strip() or "/app/app/models"
|
||||
legacy_default_model = os.getenv("WORKER_DEFAULT_MODEL", "realesrgan-x4plus").strip() or "realesrgan-x4plus"
|
||||
realesrgan_model_dir = os.getenv(
|
||||
"WORKER_REALESRGAN_MODEL_DIR",
|
||||
legacy_model_dir if legacy_model_dir != "/app/app/models" else "/app/models",
|
||||
).strip() or (legacy_model_dir if legacy_model_dir != "/app/app/models" else "/app/models")
|
||||
|
||||
return Settings(
|
||||
host=os.getenv("WORKER_HOST", "0.0.0.0").strip() or "0.0.0.0",
|
||||
port=_env_int("WORKER_PORT", 8095),
|
||||
token=os.getenv("WORKER_TOKEN", "change-this-token").strip(),
|
||||
engine=os.getenv("WORKER_ENGINE", "pillow").strip().lower() or "pillow",
|
||||
device=os.getenv("WORKER_DEVICE", "cpu").strip().lower() or "cpu",
|
||||
max_upload_mb=max(1, _env_int("WORKER_MAX_UPLOAD_MB", 20)),
|
||||
max_input_width=max(1, _env_int("WORKER_MAX_INPUT_WIDTH", 4096)),
|
||||
max_input_height=max(1, _env_int("WORKER_MAX_INPUT_HEIGHT", 4096)),
|
||||
max_output_width=max(1, _env_int("WORKER_MAX_OUTPUT_WIDTH", 8192)),
|
||||
max_output_height=max(1, _env_int("WORKER_MAX_OUTPUT_HEIGHT", 8192)),
|
||||
tmp_dir=os.getenv("WORKER_TMP_DIR", "/app/storage/tmp").strip() or "/app/storage/tmp",
|
||||
output_dir=os.getenv("WORKER_OUTPUT_DIR", "/app/storage/output").strip() or "/app/storage/output",
|
||||
result_ttl_minutes=max(1, _env_int("WORKER_RESULT_TTL_MINUTES", 60)),
|
||||
model_dir=legacy_model_dir,
|
||||
default_model=legacy_default_model,
|
||||
realesrgan_bin=os.getenv("WORKER_REALESRGAN_BIN", "/app/bin/realesrgan-ncnn-vulkan").strip() or "/app/bin/realesrgan-ncnn-vulkan",
|
||||
realesrgan_model_dir=realesrgan_model_dir,
|
||||
realesrgan_default_model=os.getenv("WORKER_REALESRGAN_DEFAULT_MODEL", legacy_default_model).strip() or legacy_default_model,
|
||||
realesrgan_anime_model=os.getenv("WORKER_REALESRGAN_ANIME_MODEL", "realesrgan-x4plus-anime").strip() or "realesrgan-x4plus-anime",
|
||||
realesrgan_gpu_id=_env_int("WORKER_REALESRGAN_GPU_ID", -1),
|
||||
realesrgan_tile=max(0, _env_int("WORKER_REALESRGAN_TILE", 0)),
|
||||
realesrgan_tta=_env_bool("WORKER_REALESRGAN_TTA", False),
|
||||
realesrgan_verbose=_env_bool("WORKER_REALESRGAN_VERBOSE", False),
|
||||
realesrgan_timeout_seconds=max(1, _env_int("WORKER_REALESRGAN_TIMEOUT_SECONDS", 900)),
|
||||
realesrgan_preprocess_max_pixels=max(1, _env_int("WORKER_REALESRGAN_PREPROCESS_MAX_PIXELS", 16_777_216)),
|
||||
realesrgan_output_ext=os.getenv("WORKER_REALESRGAN_OUTPUT_EXT", "webp").strip().lower() or "webp",
|
||||
realesrgan_allow_model_fallback=_env_bool("WORKER_REALESRGAN_ALLOW_MODEL_FALLBACK", True),
|
||||
)
|
||||
12
services/enhance-worker/app/engines/__init__.py
Normal file
12
services/enhance-worker/app/engines/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from .base import EngineHealth, UpscaleEngine, UpscaleEngineUnavailable, UpscaleResult
|
||||
from .pillow_engine import PillowUpscaleEngine
|
||||
from .realesrgan_ncnn_engine import RealEsrganNcnnEngine
|
||||
|
||||
__all__ = [
|
||||
"EngineHealth",
|
||||
"PillowUpscaleEngine",
|
||||
"RealEsrganNcnnEngine",
|
||||
"UpscaleEngine",
|
||||
"UpscaleEngineUnavailable",
|
||||
"UpscaleResult",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
43
services/enhance-worker/app/engines/base.py
Normal file
43
services/enhance-worker/app/engines/base.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ..image_io import DownloadedImage
|
||||
|
||||
|
||||
class UpscaleEngineUnavailable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpscaleResult:
|
||||
image: Image.Image
|
||||
metadata: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineHealth:
|
||||
status: str
|
||||
engine: str
|
||||
device: str
|
||||
models_loaded: bool
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class UpscaleEngine(ABC):
|
||||
@abstractmethod
|
||||
def health(self) -> EngineHealth:
|
||||
raise NotImplementedError
|
||||
|
||||
def available(self) -> bool:
|
||||
health = self.health()
|
||||
|
||||
return health.status == "ok" and health.models_loaded
|
||||
|
||||
@abstractmethod
|
||||
def upscale(self, downloaded: DownloadedImage, scale: int, mode: str, output_format: str) -> UpscaleResult:
|
||||
raise NotImplementedError
|
||||
71
services/enhance-worker/app/engines/pillow_engine.py
Normal file
71
services/enhance-worker/app/engines/pillow_engine.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
from ..config import Settings
|
||||
from ..image_io import DownloadedImage, load_normalized_image
|
||||
from .base import EngineHealth, UpscaleEngine, UpscaleResult
|
||||
|
||||
|
||||
MODE_PROFILES = {
|
||||
"standard": {"profile": "general", "sharpen_percent": 120, "radius": 1.0, "threshold": 3},
|
||||
"artwork": {"profile": "artwork", "sharpen_percent": 150, "radius": 1.2, "threshold": 2},
|
||||
"photo": {"profile": "photo", "sharpen_percent": 95, "radius": 0.8, "threshold": 4},
|
||||
"illustration": {"profile": "illustration", "sharpen_percent": 135, "radius": 1.0, "threshold": 2},
|
||||
}
|
||||
|
||||
|
||||
class PillowUpscaleEngine(UpscaleEngine):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def health(self) -> EngineHealth:
|
||||
return EngineHealth(
|
||||
status="ok",
|
||||
engine="pillow",
|
||||
device=self.settings.device,
|
||||
models_loaded=True,
|
||||
)
|
||||
|
||||
def upscale(self, downloaded: DownloadedImage, scale: int, mode: str, output_format: str) -> UpscaleResult:
|
||||
started_at = time.perf_counter()
|
||||
profile = MODE_PROFILES[mode]
|
||||
image = load_normalized_image(downloaded.path)
|
||||
width, height = image.size
|
||||
target_width = width * scale
|
||||
target_height = height * scale
|
||||
|
||||
if target_width > self.settings.max_output_width or target_height > self.settings.max_output_height:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
result = image.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||
result = result.filter(
|
||||
ImageFilter.UnsharpMask(
|
||||
radius=profile["radius"],
|
||||
percent=profile["sharpen_percent"],
|
||||
threshold=profile["threshold"],
|
||||
)
|
||||
)
|
||||
|
||||
return UpscaleResult(
|
||||
image=result,
|
||||
metadata={
|
||||
"engine": "pillow",
|
||||
"model": "pillow-lanczos",
|
||||
"requested_scale": scale,
|
||||
"native_model_scale": scale,
|
||||
"mode": mode,
|
||||
"device": self.settings.device,
|
||||
"profile": profile["profile"],
|
||||
"real_ai_upscale": False,
|
||||
"processing_seconds": round(time.perf_counter() - started_at, 3),
|
||||
"input_width": width,
|
||||
"input_height": height,
|
||||
"output_width": target_width,
|
||||
"output_height": target_height,
|
||||
"output_format": output_format,
|
||||
},
|
||||
)
|
||||
214
services/enhance-worker/app/engines/realesrgan_ncnn_engine.py
Normal file
214
services/enhance-worker/app/engines/realesrgan_ncnn_engine.py
Normal file
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from PIL import Image
|
||||
|
||||
from ..config import Settings
|
||||
from ..image_io import DownloadedImage, delete_temp_file, prepare_input_for_engine, validate_generated_image
|
||||
from .base import EngineHealth, UpscaleEngine, UpscaleEngineUnavailable, UpscaleResult
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("skinbase.enhance_worker.realesrgan_ncnn")
|
||||
|
||||
MODE_MODEL_MAP = {
|
||||
"standard": "default",
|
||||
"artwork": "default",
|
||||
"photo": "default",
|
||||
"illustration": "anime",
|
||||
}
|
||||
|
||||
|
||||
class RealEsrganNcnnEngine(UpscaleEngine):
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def health(self) -> EngineHealth:
|
||||
available_models = self.available_models()
|
||||
binary_path = Path(self.settings.realesrgan_bin)
|
||||
model_dir = Path(self.settings.realesrgan_model_dir)
|
||||
binary_exists = binary_path.exists()
|
||||
binary_executable = binary_exists and binary_path.is_file() and os.access(binary_path, os.X_OK)
|
||||
model_dir_exists = model_dir.exists() and model_dir.is_dir()
|
||||
models_loaded = self.settings.realesrgan_default_model in available_models
|
||||
|
||||
return EngineHealth(
|
||||
status="ok" if binary_exists and binary_executable and model_dir_exists and models_loaded else "degraded",
|
||||
engine="realesrgan-ncnn",
|
||||
device=self.settings.device,
|
||||
models_loaded=models_loaded,
|
||||
details={
|
||||
"realesrgan": {
|
||||
"binary_configured": self.settings.realesrgan_bin.strip() != "",
|
||||
"binary_exists": binary_exists,
|
||||
"binary_executable": binary_executable,
|
||||
"model_dir_exists": model_dir_exists,
|
||||
"available_models": available_models,
|
||||
"default_model": self.settings.realesrgan_default_model,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def available_models(self) -> list[str]:
|
||||
model_dir = Path(self.settings.realesrgan_model_dir)
|
||||
|
||||
if not model_dir.exists() or not model_dir.is_dir():
|
||||
return []
|
||||
|
||||
params = {path.stem for path in model_dir.glob("*.param")}
|
||||
bins = {path.stem for path in model_dir.glob("*.bin")}
|
||||
|
||||
return sorted(params & bins)
|
||||
|
||||
def upscale(self, downloaded: DownloadedImage, scale: int, mode: str, output_format: str) -> UpscaleResult:
|
||||
if self.health().status != "ok":
|
||||
raise UpscaleEngineUnavailable("Upscale engine is not available. Check model files and worker installation.")
|
||||
|
||||
prepared = prepare_input_for_engine(downloaded, self.settings)
|
||||
temp_output = Path(self.settings.tmp_dir) / f"realesrgan-output-{uuid.uuid4().hex}.png"
|
||||
started_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
requested_model, used_model, model_fallback = self.resolve_model(mode)
|
||||
command = self.build_command(prepared.path, temp_output, used_model)
|
||||
self.run_command(command)
|
||||
|
||||
native_scale = 4
|
||||
image, _, _, _, _ = validate_generated_image(
|
||||
temp_output,
|
||||
self.settings,
|
||||
expected_width=prepared.width * native_scale,
|
||||
expected_height=prepared.height * native_scale,
|
||||
)
|
||||
|
||||
post_downsampled = False
|
||||
if scale == 2:
|
||||
image = image.resize((prepared.width * 2, prepared.height * 2), Image.Resampling.LANCZOS)
|
||||
post_downsampled = True
|
||||
|
||||
output_width, output_height = image.size
|
||||
|
||||
if output_width > self.settings.max_output_width or output_height > self.settings.max_output_height:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Upscaled output exceeded the maximum allowed dimensions.",
|
||||
)
|
||||
|
||||
return UpscaleResult(
|
||||
image=image,
|
||||
metadata={
|
||||
"engine": "realesrgan-ncnn",
|
||||
"model": used_model,
|
||||
"requested_model": requested_model,
|
||||
"used_model": used_model,
|
||||
"model_fallback": model_fallback,
|
||||
"requested_scale": scale,
|
||||
"native_model_scale": native_scale,
|
||||
"post_downsampled": post_downsampled,
|
||||
"mode": mode,
|
||||
"device": self.settings.device,
|
||||
"processing_seconds": round(time.perf_counter() - started_at, 3),
|
||||
"input_width": prepared.width,
|
||||
"input_height": prepared.height,
|
||||
"output_width": output_width,
|
||||
"output_height": output_height,
|
||||
"output_format": output_format,
|
||||
"real_ai_upscale": True,
|
||||
"configured_output_ext": self.settings.realesrgan_output_ext,
|
||||
},
|
||||
)
|
||||
finally:
|
||||
delete_temp_file(prepared.path)
|
||||
delete_temp_file(temp_output)
|
||||
|
||||
def resolve_model(self, mode: str) -> tuple[str, str, bool]:
|
||||
available_models = set(self.available_models())
|
||||
requested_model = self.settings.realesrgan_default_model
|
||||
|
||||
if MODE_MODEL_MAP.get(mode) == "anime":
|
||||
requested_model = self.settings.realesrgan_anime_model
|
||||
|
||||
if requested_model in available_models:
|
||||
return requested_model, requested_model, False
|
||||
|
||||
if self.settings.realesrgan_allow_model_fallback and self.settings.realesrgan_default_model in available_models:
|
||||
return requested_model, self.settings.realesrgan_default_model, True
|
||||
|
||||
raise UpscaleEngineUnavailable("Upscale engine is not available. Check model files and worker installation.")
|
||||
|
||||
def build_command(self, input_path: Path, output_path: Path, model_name: str) -> list[str]:
|
||||
command = [
|
||||
self.settings.realesrgan_bin,
|
||||
"-i",
|
||||
str(input_path),
|
||||
"-o",
|
||||
str(output_path),
|
||||
"-n",
|
||||
model_name,
|
||||
"-m",
|
||||
self.settings.realesrgan_model_dir,
|
||||
]
|
||||
|
||||
if self.settings.realesrgan_gpu_id >= 0:
|
||||
command.extend(["-g", str(self.settings.realesrgan_gpu_id)])
|
||||
|
||||
if self.settings.realesrgan_tile > 0:
|
||||
command.extend(["-t", str(self.settings.realesrgan_tile)])
|
||||
|
||||
if self.settings.realesrgan_tta:
|
||||
command.append("-x")
|
||||
|
||||
if self.settings.realesrgan_verbose:
|
||||
command.append("-v")
|
||||
|
||||
return command
|
||||
|
||||
def run_command(self, command: list[str]) -> None:
|
||||
import signal
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
start_new_session=True, # new process group so we can kill all descendants
|
||||
)
|
||||
except FileNotFoundError as exception:
|
||||
raise UpscaleEngineUnavailable("Upscale engine is not available. Check model files and worker installation.") from exception
|
||||
|
||||
pgid = os.getpgid(proc.pid)
|
||||
|
||||
def _kill_group() -> None:
|
||||
try:
|
||||
os.killpg(pgid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=self.settings.realesrgan_timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_group()
|
||||
proc.communicate()
|
||||
LOGGER.warning("Real-ESRGAN ncnn command timed out after %s seconds", self.settings.realesrgan_timeout_seconds)
|
||||
raise UpscaleEngineUnavailable("Upscale engine is not available. Check model files and worker installation.")
|
||||
except BaseException:
|
||||
# Thread cancellation or other unexpected error — ensure the process is killed
|
||||
_kill_group()
|
||||
proc.communicate()
|
||||
raise
|
||||
|
||||
if proc.returncode != 0:
|
||||
LOGGER.warning(
|
||||
"Real-ESRGAN ncnn command failed with code %s; stdout bytes=%s stderr bytes=%s",
|
||||
proc.returncode,
|
||||
len(stdout or ""),
|
||||
len(stderr or ""),
|
||||
)
|
||||
raise UpscaleEngineUnavailable("Upscale engine is not available. Check model files and worker installation.")
|
||||
236
services/enhance-worker/app/image_io.py
Normal file
236
services/enhance-worker/app/image_io.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .config import Settings
|
||||
|
||||
|
||||
ALLOWED_MIMES = {"image/jpeg", "image/png", "image/webp"}
|
||||
FORMAT_TO_MIME = {"jpg": "image/jpeg", "png": "image/png", "webp": "image/webp"}
|
||||
FORMAT_TO_EXTENSION = {"JPEG": "jpg", "PNG": "png", "WEBP": "webp"}
|
||||
OUTPUT_FORMATS = {"jpg": "JPEG", "png": "PNG", "webp": "WEBP"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownloadedImage:
|
||||
path: Path
|
||||
width: int
|
||||
height: int
|
||||
mime: str
|
||||
filesize: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoredImage:
|
||||
filename: str
|
||||
path: Path
|
||||
width: int
|
||||
height: int
|
||||
filesize: int
|
||||
mime: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedImage:
|
||||
path: Path
|
||||
width: int
|
||||
height: int
|
||||
mime: str
|
||||
|
||||
|
||||
def ensure_directories(settings: Settings) -> None:
|
||||
Path(settings.tmp_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.output_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.model_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.realesrgan_model_dir).mkdir(parents=True, exist_ok=True)
|
||||
Path(settings.realesrgan_bin).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def cleanup_expired_files(settings: Settings) -> None:
|
||||
threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.result_ttl_minutes)
|
||||
|
||||
for directory in (Path(settings.tmp_dir), Path(settings.output_dir)):
|
||||
if not directory.exists():
|
||||
continue
|
||||
|
||||
for item in directory.iterdir():
|
||||
if not item.is_file():
|
||||
continue
|
||||
|
||||
modified_at = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
|
||||
|
||||
if modified_at <= threshold:
|
||||
item.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def validate_image_bytes(binary: bytes, max_width: int, max_height: int) -> tuple[int, int, str]:
|
||||
try:
|
||||
with Image.open(io.BytesIO(binary)) as image:
|
||||
width, height = image.size
|
||||
mime = Image.MIME.get(image.format or "", "").lower()
|
||||
except Exception as exc: # pragma: no cover - Pillow raises multiple subclasses.
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.") from exc
|
||||
|
||||
if mime not in ALLOWED_MIMES:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
if width < 1 or height < 1 or width > max_width or height > max_height:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
return width, height, mime
|
||||
|
||||
|
||||
def download_source_image(source_url: str, settings: Settings) -> DownloadedImage:
|
||||
max_bytes = settings.max_upload_mb * 1024 * 1024
|
||||
|
||||
try:
|
||||
with httpx.stream("GET", source_url, follow_redirects=True, timeout=30.0) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
content_length = response.headers.get("content-length")
|
||||
if content_length and int(content_length) > max_bytes:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
buffer = bytearray()
|
||||
for chunk in response.iter_bytes():
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > max_bytes:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
binary = bytes(buffer)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="The source file could not be downloaded by the worker.",
|
||||
) from exc
|
||||
|
||||
width, height, mime = validate_image_bytes(binary, settings.max_input_width, settings.max_input_height)
|
||||
extension = mime.split("/")[-1].replace("jpeg", "jpg")
|
||||
path = Path(settings.tmp_dir) / f"input-{uuid.uuid4().hex}.{extension}"
|
||||
path.write_bytes(binary)
|
||||
|
||||
return DownloadedImage(path=path, width=width, height=height, mime=mime, filesize=len(binary))
|
||||
|
||||
|
||||
def save_output_image(image: Image.Image, output_format: str, settings: Settings, job_id: int) -> StoredImage:
|
||||
width, height = image.size
|
||||
|
||||
if width < 1 or height < 1 or width > settings.max_output_width or height > settings.max_output_height:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
target_format = OUTPUT_FORMATS[output_format]
|
||||
filename = f"job-{job_id}-{uuid.uuid4().hex}.{FORMAT_TO_EXTENSION[target_format]}"
|
||||
path = Path(settings.output_dir) / filename
|
||||
save_image = image
|
||||
|
||||
if target_format == "JPEG" and image.mode not in {"RGB", "L"}:
|
||||
save_image = image.convert("RGB")
|
||||
|
||||
kwargs: dict[str, int] = {}
|
||||
if target_format == "WEBP":
|
||||
kwargs = {"quality": 90, "method": 6}
|
||||
elif target_format == "JPEG":
|
||||
kwargs = {"quality": 92}
|
||||
|
||||
save_image.save(path, target_format, **kwargs)
|
||||
|
||||
return StoredImage(
|
||||
filename=filename,
|
||||
path=path,
|
||||
width=width,
|
||||
height=height,
|
||||
filesize=path.stat().st_size,
|
||||
mime=FORMAT_TO_MIME[output_format],
|
||||
)
|
||||
|
||||
|
||||
def prepare_input_for_engine(downloaded: DownloadedImage, settings: Settings) -> PreparedImage:
|
||||
image = load_normalized_image(downloaded.path)
|
||||
width, height = image.size
|
||||
|
||||
if width * height > settings.realesrgan_preprocess_max_pixels:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
prepared_path = Path(settings.tmp_dir) / f"prepared-{uuid.uuid4().hex}.png"
|
||||
prepared_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
prepared_image = image
|
||||
|
||||
if prepared_image.mode not in {"RGB", "RGBA", "L", "LA"}:
|
||||
prepared_image = prepared_image.convert("RGBA" if "A" in prepared_image.getbands() else "RGB")
|
||||
|
||||
prepared_image.save(prepared_path, "PNG")
|
||||
|
||||
return PreparedImage(
|
||||
path=prepared_path,
|
||||
width=width,
|
||||
height=height,
|
||||
mime="image/png",
|
||||
)
|
||||
|
||||
|
||||
def validate_generated_image(
|
||||
path: Path,
|
||||
settings: Settings,
|
||||
*,
|
||||
expected_width: int | None = None,
|
||||
expected_height: int | None = None,
|
||||
) -> tuple[Image.Image, int, int, int, str]:
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
filesize = path.stat().st_size
|
||||
|
||||
if filesize <= 0:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
image = load_normalized_image(path)
|
||||
width, height = image.size
|
||||
|
||||
if width > settings.max_output_width or height > settings.max_output_height:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Upscaled output exceeded the maximum allowed dimensions.",
|
||||
)
|
||||
|
||||
if expected_width is not None and expected_height is not None and (width != expected_width or height != expected_height):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
mime = Image.MIME.get(image.format or "", "").lower() or "image/png"
|
||||
|
||||
if mime not in ALLOWED_MIMES:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Worker rejected the image.")
|
||||
|
||||
return image, width, height, filesize, mime
|
||||
|
||||
|
||||
def delete_temp_file(path: Path | None) -> None:
|
||||
if path is None:
|
||||
return
|
||||
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def resolve_result_path(settings: Settings, filename: str) -> Path:
|
||||
safe_name = os.path.basename(filename)
|
||||
if safe_name != filename or safe_name == "":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
return Path(settings.output_dir) / safe_name
|
||||
|
||||
|
||||
def load_normalized_image(path: Path) -> Image.Image:
|
||||
with Image.open(path) as image:
|
||||
normalized = ImageOps.exif_transpose(image)
|
||||
normalized.load()
|
||||
return normalized
|
||||
100
services/enhance-worker/app/main.py
Normal file
100
services/enhance-worker/app/main.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from .config import Settings, get_settings
|
||||
from .image_io import (
|
||||
cleanup_expired_files,
|
||||
delete_temp_file,
|
||||
download_source_image,
|
||||
ensure_directories,
|
||||
resolve_result_path,
|
||||
save_output_image,
|
||||
)
|
||||
from .schemas import HealthResponse, UpscaleRequest, UpscaleResponse
|
||||
from .security import verify_bearer_token
|
||||
from .upscaler import UpscaleEngineUnavailable, build_upscaler
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
app = FastAPI(title="skinbase-enhance-worker", version="1.0.0")
|
||||
resolved_settings = settings or get_settings()
|
||||
ensure_directories(resolved_settings)
|
||||
cleanup_expired_files(resolved_settings)
|
||||
app.state.settings = resolved_settings
|
||||
app.state.upscaler = build_upscaler(resolved_settings)
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
def health() -> HealthResponse:
|
||||
engine_health = app.state.upscaler.health()
|
||||
|
||||
with suppress(Exception):
|
||||
cleanup_expired_files(app.state.settings)
|
||||
|
||||
return HealthResponse(
|
||||
status=engine_health.status,
|
||||
service="skinbase-enhance-worker",
|
||||
engine=engine_health.engine,
|
||||
device=engine_health.device,
|
||||
models_loaded=engine_health.models_loaded,
|
||||
max_input_width=app.state.settings.max_input_width,
|
||||
max_input_height=app.state.settings.max_input_height,
|
||||
max_output_width=app.state.settings.max_output_width,
|
||||
max_output_height=app.state.settings.max_output_height,
|
||||
realesrgan=engine_health.details.get("realesrgan"),
|
||||
)
|
||||
|
||||
@app.post("/v1/upscale", response_model=UpscaleResponse)
|
||||
def upscale(payload: UpscaleRequest, request: Request, _: None = Depends(verify_bearer_token)):
|
||||
cleanup_expired_files(app.state.settings)
|
||||
downloaded = None
|
||||
|
||||
try:
|
||||
downloaded = download_source_image(payload.source_url, app.state.settings)
|
||||
result = app.state.upscaler.upscale(downloaded, payload.scale, payload.mode, payload.output_format)
|
||||
stored = save_output_image(result.image, payload.output_format, app.state.settings, payload.job_id)
|
||||
|
||||
return UpscaleResponse(
|
||||
success=True,
|
||||
job_id=payload.job_id,
|
||||
output_url=str(request.base_url).rstrip("/") + f"/v1/results/{stored.filename}",
|
||||
width=stored.width,
|
||||
height=stored.height,
|
||||
filesize=stored.filesize,
|
||||
mime=stored.mime,
|
||||
metadata=result.metadata,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
return JSONResponse(status_code=exc.status_code, content={"success": False, "error": exc.detail})
|
||||
except UpscaleEngineUnavailable as exc:
|
||||
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content={"success": False, "error": str(exc)})
|
||||
finally:
|
||||
delete_temp_file(downloaded.path if downloaded is not None else None)
|
||||
|
||||
@app.get("/v1/results/{filename}")
|
||||
def result(filename: str):
|
||||
path = resolve_result_path(app.state.settings, filename)
|
||||
|
||||
if not path.exists() or not path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
return FileResponse(path)
|
||||
|
||||
@app.delete("/v1/results/{filename}")
|
||||
def delete_result(filename: str, _: None = Depends(verify_bearer_token)):
|
||||
path = resolve_result_path(app.state.settings, filename)
|
||||
|
||||
if not path.exists() or not path.is_file():
|
||||
return {"success": True, "deleted": False}
|
||||
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
return {"success": True, "deleted": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
1
services/enhance-worker/app/models/.gitkeep
Normal file
1
services/enhance-worker/app/models/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
keep
|
||||
64
services/enhance-worker/app/schemas.py
Normal file
64
services/enhance-worker/app/schemas.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
SUPPORTED_MODES = {"standard", "artwork", "photo", "illustration"}
|
||||
SUPPORTED_FORMATS = {"webp", "png", "jpg"}
|
||||
SUPPORTED_SCALES = {2, 4}
|
||||
|
||||
|
||||
class UpscaleRequest(BaseModel):
|
||||
job_id: int = Field(..., gt=0)
|
||||
source_url: str
|
||||
scale: Literal[2, 4]
|
||||
mode: Literal["standard", "artwork", "photo", "illustration"]
|
||||
output_format: Literal["webp", "png", "jpg"] = "webp"
|
||||
|
||||
@field_validator("source_url")
|
||||
@classmethod
|
||||
def validate_source_url(cls, value: str) -> str:
|
||||
candidate = value.strip()
|
||||
|
||||
if candidate == "":
|
||||
raise ValueError("source_url is required")
|
||||
|
||||
if candidate.startswith(("/", "./", "../", "file://")):
|
||||
raise ValueError("source_url must be an http or https URL")
|
||||
|
||||
parsed = urlparse(candidate)
|
||||
|
||||
if parsed.scheme not in {"http", "https"} or parsed.netloc == "":
|
||||
raise ValueError("source_url must be an http or https URL")
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
engine: str
|
||||
device: str
|
||||
models_loaded: bool
|
||||
max_input_width: int
|
||||
max_input_height: int
|
||||
max_output_width: int
|
||||
max_output_height: int
|
||||
realesrgan: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class UpscaleResponse(BaseModel):
|
||||
success: bool
|
||||
job_id: int
|
||||
output_url: str | None = None
|
||||
output_base64: str | None = None
|
||||
width: int
|
||||
height: int
|
||||
filesize: int
|
||||
mime: str
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
12
services/enhance-worker/app/security.py
Normal file
12
services/enhance-worker/app/security.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
|
||||
def verify_bearer_token(request: Request) -> None:
|
||||
settings = request.app.state.settings
|
||||
authorization = request.headers.get("Authorization", "")
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
|
||||
if scheme.lower() != "bearer" or token != settings.token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
|
||||
36
services/enhance-worker/app/upscaler.py
Normal file
36
services/enhance-worker/app/upscaler.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import Settings
|
||||
from .engines.base import EngineHealth, UpscaleEngine, UpscaleEngineUnavailable, UpscaleResult
|
||||
from .engines.pillow_engine import PillowUpscaleEngine
|
||||
from .engines.realesrgan_ncnn_engine import RealEsrganNcnnEngine
|
||||
from .image_io import DownloadedImage
|
||||
|
||||
|
||||
class UnavailableEngine(UpscaleEngine):
|
||||
def __init__(self, settings: Settings, engine_name: str) -> None:
|
||||
self.settings = settings
|
||||
self.engine_name = engine_name
|
||||
|
||||
def health(self) -> EngineHealth:
|
||||
return EngineHealth(
|
||||
status="degraded",
|
||||
engine=self.engine_name,
|
||||
device=self.settings.device,
|
||||
models_loaded=False,
|
||||
)
|
||||
|
||||
def upscale(self, downloaded: DownloadedImage, scale: int, mode: str, output_format: str) -> UpscaleResult:
|
||||
raise UpscaleEngineUnavailable("Upscale engine is not available. Check model files and worker installation.")
|
||||
|
||||
|
||||
def build_upscaler(settings: Settings) -> UpscaleEngine:
|
||||
engine_name = settings.engine.strip().lower()
|
||||
|
||||
if engine_name == "pillow":
|
||||
return PillowUpscaleEngine(settings)
|
||||
|
||||
if engine_name in {"realesrgan", "realesrgan-ncnn"}:
|
||||
return RealEsrganNcnnEngine(settings)
|
||||
|
||||
return UnavailableEngine(settings, engine_name or "unknown")
|
||||
Reference in New Issue
Block a user