Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render

This commit is contained in:
2026-06-04 07:52:57 +02:00
parent 0b33a1b074
commit 15870ddb1f
191 changed files with 15453 additions and 1786 deletions

View 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",
]

View 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

View 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,
},
)

View 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.")