feat(canvas): 图片转换插件 - 增加本地媒体转换节点与运行时

This commit is contained in:
QAyong
2026-09-08 22:55:44 +08:00
parent 897981b893
commit b13d84e7e6
51 changed files with 3284 additions and 44 deletions

View File

@@ -0,0 +1,132 @@
"""Small persistent worker for the local ControlNet Aux lineart detector.
This worker only extracts a lineart control image. It does not load Stable
Diffusion or a ControlNet generation pipeline, so it stays suitable for a
CPU-only notebook. The Node Local Runtime owns the process and sends one JSON
request per line; stdout is reserved for that protocol.
"""
from __future__ import annotations
import base64
import contextlib
import io
import json
import os
import re
import sys
from typing import Any
from PIL import Image
MODEL_ID = os.environ.get("CANVAS_LINEART_MODEL_ID", "lllyasviel/Annotators")
MAX_INPUT_BYTES = 12 * 1024 * 1024
DATA_URL_PATTERN = re.compile(r"^data:(image/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$")
DETECTOR: Any = None
def emit(value: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n")
sys.stdout.flush()
def load_detector() -> Any:
global DETECTOR
if DETECTOR is not None:
return DETECTOR
try:
from controlnet_aux import LineartDetector
# The detector downloads only the lineart preprocessor weights from the
# configured Hugging Face cache. It is intentionally not a diffusion
# pipeline and never loads Stable Diffusion or ControlNet weights.
with contextlib.redirect_stdout(sys.stderr):
DETECTOR = LineartDetector.from_pretrained(MODEL_ID)
except Exception as error: # pragma: no cover - depends on local files
raise RuntimeError("lineart_model_missing") from error
return DETECTOR
def image_from_data_url(value: Any) -> Image.Image:
if not isinstance(value, str):
raise ValueError("lineart_input_invalid")
match = DATA_URL_PATTERN.fullmatch(value)
if not match:
raise ValueError("lineart_input_invalid")
try:
raw = base64.urlsafe_b64decode(match.group(2) + "=" * (-len(match.group(2)) % 4))
except Exception as error:
raise ValueError("lineart_input_invalid") from error
if not raw or len(raw) > MAX_INPUT_BYTES:
raise ValueError("lineart_input_invalid")
try:
image = Image.open(io.BytesIO(raw)).convert("RGB")
image.load()
return image
except Exception as error:
raise ValueError("lineart_input_invalid") from error
def encode_png(image: Image.Image) -> str:
output = io.BytesIO()
image.save(output, format="PNG", optimize=True)
return base64.b64encode(output.getvalue()).decode("ascii")
def handle(request: dict[str, Any]) -> dict[str, Any]:
image = image_from_data_url(request.get("dataUrl"))
try:
lineart = load_detector()(image)
if not isinstance(lineart, Image.Image):
raise RuntimeError("lineart_inference_failed")
return {
"ok": True,
"requestId": request.get("requestId"),
"mimeType": "image/png",
"width": lineart.width,
"height": lineart.height,
"modelId": MODEL_ID,
"device": "cpu",
"pngBase64": encode_png(lineart.convert("RGB")),
}
except RuntimeError:
raise
except Exception as error: # pragma: no cover - depends on local runtime
raise RuntimeError("lineart_inference_failed") from error
def main() -> None:
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
os.environ.setdefault("HF_HUB_OFFLINE", "1")
os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
for line in sys.stdin:
# PowerShell and a few Windows launchers may prepend a UTF-8 BOM to
# the first piped line. The Node runtime sends plain UTF-8, but
# accepting the marker keeps the worker protocol tolerant for manual
# diagnostics as well.
line = line.lstrip("\ufeff").strip()
if not line:
continue
request_id: Any = None
try:
request = json.loads(line)
if not isinstance(request, dict):
raise ValueError("lineart_input_invalid")
request_id = request.get("requestId")
emit(handle(request))
except ValueError as error:
emit({"ok": False, "requestId": request_id, "code": str(error) or "lineart_input_invalid", "message": "线稿转换输入无效"})
except RuntimeError as error:
code = str(error) or "lineart_inference_failed"
message = "本地 AI 线稿模型或依赖尚未安装" if code == "lineart_model_missing" else "本地 AI 线稿推理失败"
emit({"ok": False, "requestId": request_id, "code": code, "message": message})
except Exception:
emit({"ok": False, "requestId": request_id, "code": "lineart_inference_failed", "message": "本地 AI 线稿推理失败"})
if __name__ == "__main__":
main()