honeydo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/README.zh-CN.md +88 -0
- package/package.json +54 -0
- package/packages/cli/dist/index.d.ts +2 -0
- package/packages/cli/dist/index.js +171 -0
- package/packages/cli/dist/index.js.map +1 -0
- package/packages/doubao/dist/cli.d.ts +38 -0
- package/packages/doubao/dist/cli.d.ts.map +1 -0
- package/packages/doubao/dist/cli.js +206 -0
- package/packages/gcli/dist/cli.d.ts +465 -0
- package/packages/gcli/dist/cli.js +2017 -0
- package/packages/gcli/dist/cli.js.map +1 -0
- package/packages/lmedia/dist/index.d.ts +1 -0
- package/packages/lmedia/dist/index.js +1594 -0
- package/packages/lmedia/python/edit.py +107 -0
- package/packages/lmedia/python/esrgan_path.py +16 -0
- package/packages/lmedia/python/gen.py +131 -0
- package/packages/lmedia/python/serve.py +352 -0
- package/packages/lmedia/python/sfx.py +527 -0
- package/packages/lmedia/python/teacache.py +255 -0
- package/packages/lmedia/python/upscale.py +41 -0
- package/packages/minimax/dist/cli.d.ts +51 -0
- package/packages/minimax/dist/cli.js +307 -0
- package/packages/minimax/dist/cli.js.map +1 -0
- package/packages/minimax/dist/client.d.ts +20 -0
- package/packages/minimax/dist/client.js +55 -0
- package/packages/minimax/dist/client.js.map +1 -0
- package/packages/minimax/dist/tts.d.ts +33 -0
- package/packages/minimax/dist/tts.js +64 -0
- package/packages/minimax/dist/tts.js.map +1 -0
- package/packages/minimax/dist/validate.d.ts +29 -0
- package/packages/minimax/dist/validate.js +122 -0
- package/packages/minimax/dist/validate.js.map +1 -0
- package/packages/minimax/dist/voice-clone.d.ts +17 -0
- package/packages/minimax/dist/voice-clone.js +47 -0
- package/packages/minimax/dist/voice-clone.js.map +1 -0
- package/packages/minimax/dist/voices.d.ts +17 -0
- package/packages/minimax/dist/voices.js +20 -0
- package/packages/minimax/dist/voices.js.map +1 -0
- package/packages/qwen/dist/index.d.ts +1 -0
- package/packages/qwen/dist/index.js +311 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""edit.py - 参考图编辑驱动(QwenImageEditPlusPipeline,1+ 张参考图)。
|
|
2
|
+
* 直跑:argv[1] = JSON {prompt, out, snapshotEdit, refs: [path...], width, height, steps, trueCfg, neg, seed,
|
|
3
|
+
* loras?: [{path, scale}], lightningSched?: bool, teaCache?: {thresh}|true}
|
|
4
|
+
* serve.py 复用:load_pipe / run(daemon 常驻热路径)
|
|
5
|
+
* trueCfg: true CFG 强度(官方 Edit-2511 配方 4.0;<=1 无引导——旧版行为/蒸馏 LoRA 路径)
|
|
6
|
+
* neg: 负向提示词(官方编辑配方默认 " ",trueCfg>1 时才下发)
|
|
7
|
+
* loras: LoRA 叠加(如 lightningedit2511 蒸馏加速,scale 固定 1.0)
|
|
8
|
+
* lightningSched: Lightning 蒸馏专用调度器(base_shift=max_shift=ln3、shift_terminal=None;
|
|
9
|
+
* 蒸馏分布外 shift 区间 0.5-0.9 会掉质——官方 generate_with_diffusers.py 同款配置)
|
|
10
|
+
* teaCache: TeaCache 步缓存(teacache.py;质量近似无损加速, thresh 缺省 0.2)
|
|
11
|
+
* 出参:stdout 最后一行 JSON {out, seconds, teaCache?}
|
|
12
|
+
* 历史坑:guidance_scale 被 diffusers 静默忽略,真实旋钮 true_cfg_scale+negative_prompt——2026-08 修复。
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
import torch
|
|
21
|
+
from diffusers import FlowMatchEulerDiscreteScheduler, QwenImageEditPlusPipeline
|
|
22
|
+
from PIL import Image
|
|
23
|
+
|
|
24
|
+
# Lightning 蒸馏 LoRA 调度器(与 gen.py 同名定义逐字段一致;改参数两处需同步——serve.py 用 gen 的)
|
|
25
|
+
LIGHTNING_SCHEDULER_CONFIG = {
|
|
26
|
+
"base_image_seq_len": 256,
|
|
27
|
+
"base_shift": math.log(3),
|
|
28
|
+
"invert_sigmas": False,
|
|
29
|
+
"max_image_seq_len": 8192,
|
|
30
|
+
"max_shift": math.log(3),
|
|
31
|
+
"num_train_timesteps": 1000,
|
|
32
|
+
"shift": 1.0,
|
|
33
|
+
"shift_terminal": None,
|
|
34
|
+
"stochastic_sampling": False,
|
|
35
|
+
"time_shift_type": "exponential",
|
|
36
|
+
"use_beta_sigmas": False,
|
|
37
|
+
"use_dynamic_shifting": True,
|
|
38
|
+
"use_exponential_sigmas": False,
|
|
39
|
+
"use_karras_sigmas": False,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_pipe(
|
|
44
|
+
snapshot_edit: str, loras: list | None = None, lightning_sched: bool = False
|
|
45
|
+
) -> QwenImageEditPlusPipeline:
|
|
46
|
+
"""from_pretrained(bf16) → LoRA(CPU 上,须在 to("mps") 前)→ 可选蒸馏调度器 → to("mps")。"""
|
|
47
|
+
pipe = QwenImageEditPlusPipeline.from_pretrained(snapshot_edit, torch_dtype=torch.bfloat16)
|
|
48
|
+
if loras:
|
|
49
|
+
names = []
|
|
50
|
+
for i, l in enumerate(loras):
|
|
51
|
+
name = f"l{i}"
|
|
52
|
+
pipe.load_lora_weights(l["path"], adapter_name=name)
|
|
53
|
+
names.append(name)
|
|
54
|
+
pipe.set_adapters(names, adapter_weights=[l.get("scale", 1.0) for l in loras])
|
|
55
|
+
if lightning_sched:
|
|
56
|
+
pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(LIGHTNING_SCHEDULER_CONFIG)
|
|
57
|
+
return pipe.to("mps")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run(pipe: QwenImageEditPlusPipeline, cfg: dict) -> dict:
|
|
61
|
+
"""一次编辑并落盘,返回结果 dict(不 print)。
|
|
62
|
+
refVaeSize: 实验开关——monkeypatch 管线 VAE_IMAGE_SIZE(官方 1024² 面积),
|
|
63
|
+
参考图 token 占序列 ~64%(4ref@2048),降面积是出版档候选加速;任务后恢复原值(daemon 安全)。"""
|
|
64
|
+
from teacache import apply_teacache
|
|
65
|
+
|
|
66
|
+
tc = apply_teacache(pipe, cfg)
|
|
67
|
+
t0 = time.time()
|
|
68
|
+
true_cfg = float(cfg.get("trueCfg", 1.0))
|
|
69
|
+
neg = cfg.get("neg") if true_cfg > 1.0 else None
|
|
70
|
+
refs = [Image.open(p).convert("RGB") for p in cfg["refs"]]
|
|
71
|
+
|
|
72
|
+
import diffusers.pipelines.qwenimage.pipeline_qwenimage_edit_plus as plus_mod
|
|
73
|
+
|
|
74
|
+
old_vae_size = plus_mod.VAE_IMAGE_SIZE
|
|
75
|
+
if cfg.get("refVaeSize"):
|
|
76
|
+
plus_mod.VAE_IMAGE_SIZE = int(cfg["refVaeSize"])
|
|
77
|
+
try:
|
|
78
|
+
img = pipe(
|
|
79
|
+
image=refs,
|
|
80
|
+
prompt=cfg["prompt"],
|
|
81
|
+
negative_prompt=neg,
|
|
82
|
+
true_cfg_scale=true_cfg,
|
|
83
|
+
width=cfg.get("width", 1664),
|
|
84
|
+
height=cfg.get("height", 928),
|
|
85
|
+
num_inference_steps=cfg.get("steps", 20),
|
|
86
|
+
generator=torch.Generator(device="mps").manual_seed(cfg.get("seed", 42)),
|
|
87
|
+
).images[0]
|
|
88
|
+
finally:
|
|
89
|
+
plus_mod.VAE_IMAGE_SIZE = old_vae_size
|
|
90
|
+
img.save(cfg["out"])
|
|
91
|
+
result = {"out": cfg["out"], "seconds": round(time.time() - t0, 1)}
|
|
92
|
+
if tc is not None:
|
|
93
|
+
result["teaCache"] = tc.stats()
|
|
94
|
+
if cfg.get("refVaeSize"):
|
|
95
|
+
result["refVaeSize"] = int(cfg["refVaeSize"])
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
cfg = json.loads(sys.argv[1])
|
|
101
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
102
|
+
pipe = load_pipe(cfg["snapshotEdit"], cfg.get("loras"), bool(cfg.get("lightningSched")))
|
|
103
|
+
print(json.dumps(run(pipe, cfg), ensure_ascii=False))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Real-ESRGAN 权重路径解析:env LMEDIA_REALESRGAN_PATH > ~/.cache/lmedia/ > 历史 /tmp 位"""
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
CACHE_PATH = os.path.expanduser("~/.cache/lmedia/RealESRGAN_x2.pth")
|
|
5
|
+
LEGACY_TMP_PATH = "/tmp/RealESRGAN_x2.pth"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_esrgan_path() -> str:
|
|
9
|
+
env = os.environ.get("LMEDIA_REALESRGAN_PATH")
|
|
10
|
+
if env and os.path.exists(env):
|
|
11
|
+
return env
|
|
12
|
+
if os.path.exists(CACHE_PATH):
|
|
13
|
+
return CACHE_PATH
|
|
14
|
+
if os.path.exists(LEGACY_TMP_PATH):
|
|
15
|
+
return LEGACY_TMP_PATH
|
|
16
|
+
return env or CACHE_PATH
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""gen.py - 文生图驱动(diffusers bf16 + LoRA 叠加 + 可选超分)。
|
|
2
|
+
* 直跑:argv[1] = JSON {prompt, out, snapshot, width, height, steps, trueCfg, neg, num, seed,
|
|
3
|
+
* loras: [{path, scale}], lightningSched?: bool, teaCache?: {thresh}|true,
|
|
4
|
+
* upscaleTo?: [w,h], esrganModel?}
|
|
5
|
+
* serve.py 复用:load_pipe / load_esrgan / run(daemon 常驻热路径,免每次 ~2min 冷加载)
|
|
6
|
+
* trueCfg: true CFG 强度(官方推荐 4.0;<=1 无引导——旧版行为;蒸馏 LoRA 路径用 1.0)
|
|
7
|
+
* neg: 负向提示词(trueCfg>1 时才下发;官方 2512 中文负向模板由 CLI 层注入)
|
|
8
|
+
* num: 一次生成张数(1-4),输出 out-1.png..out-N.png
|
|
9
|
+
* lightningSched: Lightning 蒸馏专用调度器(base_shift=max_shift=ln3、shift_terminal=None;
|
|
10
|
+
* 用默认调度器跑蒸馏 LoRA 是分布外,会掉质——2026-08-29 修复 --fast 遗漏)
|
|
11
|
+
* teaCache: TeaCache 步缓存(teacache.py;质量近似无损加速,thresh 缺省 0.2)
|
|
12
|
+
* 出参:stdout 最后一行 JSON {out, seconds, upscaled?, outs?, teaCache?}(num=1 形状与旧版一致)
|
|
13
|
+
* 历史坑:guidance_scale 参数被 diffusers 静默忽略(仅 guidance-distilled 模型生效),
|
|
14
|
+
* 真实旋钮是 true_cfg_scale,且必须传 negative_prompt 才启用——2026-08 修复。
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
from esrgan_path import resolve_esrgan_path
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import torch
|
|
24
|
+
from diffusers import FlowMatchEulerDiscreteScheduler, QwenImagePipeline
|
|
25
|
+
from PIL import Image
|
|
26
|
+
|
|
27
|
+
# Lightning 蒸馏 LoRA 调度器(shift=3 蒸馏分布;官方 generate_with_diffusers.py 同款)
|
|
28
|
+
LIGHTNING_SCHEDULER_CONFIG = {
|
|
29
|
+
"base_image_seq_len": 256,
|
|
30
|
+
"base_shift": math.log(3),
|
|
31
|
+
"invert_sigmas": False,
|
|
32
|
+
"max_image_seq_len": 8192,
|
|
33
|
+
"max_shift": math.log(3),
|
|
34
|
+
"num_train_timesteps": 1000,
|
|
35
|
+
"shift": 1.0,
|
|
36
|
+
"shift_terminal": None,
|
|
37
|
+
"stochastic_sampling": False,
|
|
38
|
+
"time_shift_type": "exponential",
|
|
39
|
+
"use_beta_sigmas": False,
|
|
40
|
+
"use_dynamic_shifting": True,
|
|
41
|
+
"use_exponential_sigmas": False,
|
|
42
|
+
"use_karras_sigmas": False,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_pipe(snapshot: str, loras: list | None = None, lightning_sched: bool = False) -> QwenImagePipeline:
|
|
47
|
+
"""from_pretrained(bf16) → LoRA(CPU 上加载,须在 to("mps") 前)→ 可选蒸馏调度器 → to("mps")。"""
|
|
48
|
+
pipe = QwenImagePipeline.from_pretrained(snapshot, torch_dtype=torch.bfloat16)
|
|
49
|
+
if loras:
|
|
50
|
+
names = []
|
|
51
|
+
for i, l in enumerate(loras):
|
|
52
|
+
name = f"l{i}"
|
|
53
|
+
pipe.load_lora_weights(l["path"], adapter_name=name)
|
|
54
|
+
names.append(name)
|
|
55
|
+
pipe.set_adapters(names, adapter_weights=[l.get("scale", 1.0) for l in loras])
|
|
56
|
+
if lightning_sched:
|
|
57
|
+
pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(LIGHTNING_SCHEDULER_CONFIG)
|
|
58
|
+
return pipe.to("mps")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_esrgan(path: str | None = None):
|
|
62
|
+
"""Real-ESRGAN x2(spandrel,MPS)——懒加载,权重缺文件时抛错(doctor 有检查项)。"""
|
|
63
|
+
from spandrel import ModelLoader
|
|
64
|
+
|
|
65
|
+
return ModelLoader().load_from_file(path or resolve_esrgan_path()).to("mps").eval()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run(pipe: QwenImagePipeline, cfg: dict, sr=None) -> dict:
|
|
69
|
+
"""一次生成并落盘,返回结果 dict(不 print——print 由调用方/直跑 main 做)。
|
|
70
|
+
sr: 预载 ESRGAN 模型(daemon 复用);upscaleTo 存在且 sr=None 时现场加载。"""
|
|
71
|
+
from teacache import apply_teacache
|
|
72
|
+
|
|
73
|
+
tc = apply_teacache(pipe, cfg)
|
|
74
|
+
t0 = time.time()
|
|
75
|
+
true_cfg = float(cfg.get("trueCfg", 1.0))
|
|
76
|
+
neg = cfg.get("neg") if true_cfg > 1.0 else None # 无引导路径不传负向,避免 diffusers 警告
|
|
77
|
+
num = int(cfg.get("num", 1))
|
|
78
|
+
images = pipe(
|
|
79
|
+
prompt=cfg["prompt"],
|
|
80
|
+
negative_prompt=neg,
|
|
81
|
+
true_cfg_scale=true_cfg,
|
|
82
|
+
width=cfg.get("width", 1664),
|
|
83
|
+
height=cfg.get("height", 928),
|
|
84
|
+
num_inference_steps=cfg.get("steps", 20),
|
|
85
|
+
num_images_per_prompt=num,
|
|
86
|
+
generator=torch.Generator(device="mps").manual_seed(cfg.get("seed", 42)),
|
|
87
|
+
).images
|
|
88
|
+
|
|
89
|
+
up = cfg.get("upscaleTo")
|
|
90
|
+
if up and sr is None:
|
|
91
|
+
sr = load_esrgan(cfg.get("esrganModel"))
|
|
92
|
+
if up:
|
|
93
|
+
import numpy as np
|
|
94
|
+
|
|
95
|
+
outs = []
|
|
96
|
+
for i, im in enumerate(images, start=1):
|
|
97
|
+
if up:
|
|
98
|
+
arr = np.array(im.convert("RGB")).astype(np.float32) / 255.0
|
|
99
|
+
t = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).to("mps")
|
|
100
|
+
with torch.no_grad():
|
|
101
|
+
out_t = sr(t)
|
|
102
|
+
im = Image.fromarray(
|
|
103
|
+
(out_t.squeeze(0).permute(1, 2, 0).clamp(0, 1).cpu().numpy() * 255).astype("uint8")
|
|
104
|
+
).resize((up[0], up[1]), Image.LANCZOS)
|
|
105
|
+
if num == 1:
|
|
106
|
+
p = cfg["out"]
|
|
107
|
+
else:
|
|
108
|
+
root, ext = os.path.splitext(cfg["out"])
|
|
109
|
+
p = f"{root}-{i}{ext or '.png'}"
|
|
110
|
+
im.save(p)
|
|
111
|
+
outs.append(p)
|
|
112
|
+
|
|
113
|
+
result = {"out": outs[0], "seconds": round(time.time() - t0, 1)}
|
|
114
|
+
if num > 1:
|
|
115
|
+
result["outs"] = outs
|
|
116
|
+
if up:
|
|
117
|
+
result["upscaled"] = up
|
|
118
|
+
if tc is not None:
|
|
119
|
+
result["teaCache"] = tc.stats()
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main() -> None:
|
|
124
|
+
cfg = json.loads(sys.argv[1])
|
|
125
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1")
|
|
126
|
+
pipe = load_pipe(cfg["snapshot"], cfg.get("loras"), bool(cfg.get("lightningSched")))
|
|
127
|
+
print(json.dumps(run(pipe, cfg), ensure_ascii=False))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""serve.py — 常驻推理 daemon(stdlib-only unix socket,NDJSON 帧)。
|
|
2
|
+
* 由 TS 侧 `lmedia image serve start` 或 gen/edit 自动拉起;路径全部由 argv 传入。
|
|
3
|
+
* 顺序关键:bind+listen **先于** import torch/加载模型——加载期(~2min)客户端连接进
|
|
4
|
+
* backlog 排队,首任务等加载完执行,不回退冷路径。
|
|
5
|
+
* 线程模型:dispatcher(主线程 accept + 每连接轻线程读请求/排队/ping)
|
|
6
|
+
* + worker(唯一碰 torch 的线程,串行消费任务队列 ← GPU 串行铁律的代码化)
|
|
7
|
+
* + watchdog(空闲自退)
|
|
8
|
+
* 协议:请求一行 JSON {"kind": "gen|edit|upscale|ping", ...};
|
|
9
|
+
* 响应 0..n 个 {"t":"queued"|"log"} 帧 + 恰好 1 个 {"t":"done", ok, result|error} 帧后 close。
|
|
10
|
+
* 退出:TERM/idle 统一 unlink socket+status → os._exit(0)(绕过 MPS 退出清理挂死——2026-08 实录坑)。
|
|
11
|
+
* 依赖 gen.py/edit.py/upscale.py 的 load_pipe/run 函数(同目录 import,脚本目录自动在 sys.path)。
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import contextlib
|
|
15
|
+
import gc
|
|
16
|
+
import json
|
|
17
|
+
from esrgan_path import resolve_esrgan_path
|
|
18
|
+
import os
|
|
19
|
+
import queue
|
|
20
|
+
import re
|
|
21
|
+
import signal
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import traceback
|
|
27
|
+
|
|
28
|
+
REQUEST_LIMIT = 8 << 20 # 单行请求上限 8MB(正常 payload 10KB 级)
|
|
29
|
+
MAX_ADAPTERS = 4 # LoRA adapter 缓存上限(单个几百 MB,防无限累积)
|
|
30
|
+
|
|
31
|
+
STATE = {
|
|
32
|
+
"pid": os.getpid(), "mode": None, "state": "boot", # boot|loading|ready|busy
|
|
33
|
+
"busy": False, "queue": 0, "jobs": 0,
|
|
34
|
+
"lastJobAt": None, "startedAt": None, "snapshot": None,
|
|
35
|
+
}
|
|
36
|
+
JOBS: "queue.Queue[tuple]" = queue.Queue()
|
|
37
|
+
EXITING = threading.Event()
|
|
38
|
+
SRV: socket.socket | None = None
|
|
39
|
+
LOG = None # logfile 句柄(daemon 自身输出)
|
|
40
|
+
SOCK_PATH = STATUS_PATH = None
|
|
41
|
+
|
|
42
|
+
# —— worker 线程内初始化(延迟 import torch 后才存在)——
|
|
43
|
+
PIPE = None # QwenImagePipeline | QwenImageEditPlusPipeline
|
|
44
|
+
SR = None # (path, spandrel model) upscale 懒加载缓存
|
|
45
|
+
GEN_SR = None # gen 内联超分(upscaleTo)懒加载
|
|
46
|
+
LOADED: dict[str, str] = {} # adapter_name -> abspath
|
|
47
|
+
_ADAPTER_SEQ = 0
|
|
48
|
+
CUR_LIGHTNING = False
|
|
49
|
+
SCHED_DEFAULT_CONFIG = None # load 后的默认调度器 config(任务间恢复用)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def log(msg: str) -> None:
|
|
53
|
+
if LOG:
|
|
54
|
+
LOG.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_status() -> None:
|
|
58
|
+
tmp = STATUS_PATH + ".tmp"
|
|
59
|
+
with open(tmp, "w") as f:
|
|
60
|
+
json.dump(STATE, f, ensure_ascii=False)
|
|
61
|
+
os.replace(tmp, STATUS_PATH)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def send(conn, frame: dict) -> None:
|
|
65
|
+
"""发一帧;对端已死(EPIPE)吞掉——任务照常算完,结果丢弃。"""
|
|
66
|
+
try:
|
|
67
|
+
conn.sendall((json.dumps(frame, ensure_ascii=False) + "\n").encode())
|
|
68
|
+
except OSError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ClientWriter:
|
|
73
|
+
"""redirect_stdout/stderr 用:任务输出按 \\n 与 \\r 切帧转发客户端 + 落日志(tqdm 进度条走 \\r)。"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, conn):
|
|
76
|
+
self.conn = conn
|
|
77
|
+
self.tail = ""
|
|
78
|
+
|
|
79
|
+
def write(self, s: str) -> int:
|
|
80
|
+
self.tail += s
|
|
81
|
+
parts = re.split(r"[\r\n]+", self.tail)
|
|
82
|
+
self.tail = parts.pop()
|
|
83
|
+
for p in parts:
|
|
84
|
+
if p.strip():
|
|
85
|
+
send(self.conn, {"t": "log", "line": p})
|
|
86
|
+
log(f" | {p}")
|
|
87
|
+
return len(s)
|
|
88
|
+
|
|
89
|
+
def flush(self) -> None:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def readline(conn, limit: int = REQUEST_LIMIT) -> bytes | None:
|
|
94
|
+
buf = b""
|
|
95
|
+
while b"\n" not in buf:
|
|
96
|
+
if len(buf) > limit:
|
|
97
|
+
return None
|
|
98
|
+
try:
|
|
99
|
+
chunk = conn.recv(65536)
|
|
100
|
+
except OSError:
|
|
101
|
+
return None
|
|
102
|
+
if not chunk:
|
|
103
|
+
return None
|
|
104
|
+
buf += chunk
|
|
105
|
+
return buf.split(b"\n", 1)[0]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def status_snapshot() -> dict:
|
|
109
|
+
return {**STATE, "queue": JOBS.qsize(), "kind": "ping"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def handle_conn(conn) -> None:
|
|
113
|
+
"""dispatcher 派生的轻线程:读请求/排队/ping,不碰 torch。"""
|
|
114
|
+
try:
|
|
115
|
+
line = readline(conn)
|
|
116
|
+
if line is None:
|
|
117
|
+
conn.close()
|
|
118
|
+
return
|
|
119
|
+
try:
|
|
120
|
+
req = json.loads(line)
|
|
121
|
+
except json.JSONDecodeError:
|
|
122
|
+
send(conn, {"t": "done", "ok": False, "error": "请求不是合法 JSON"})
|
|
123
|
+
conn.close()
|
|
124
|
+
return
|
|
125
|
+
kind = req.get("kind")
|
|
126
|
+
if kind == "ping":
|
|
127
|
+
send(conn, {"t": "done", "ok": True, "result": status_snapshot()})
|
|
128
|
+
conn.close()
|
|
129
|
+
return
|
|
130
|
+
if kind not in ("gen", "edit", "upscale"):
|
|
131
|
+
send(conn, {"t": "done", "ok": False, "error": f"未知 kind: {kind}"})
|
|
132
|
+
conn.close()
|
|
133
|
+
return
|
|
134
|
+
send(conn, {"t": "queued", "position": JOBS.qsize() + 1})
|
|
135
|
+
JOBS.put((conn, req))
|
|
136
|
+
except Exception:
|
|
137
|
+
traceback.print_exc(file=LOG)
|
|
138
|
+
try:
|
|
139
|
+
conn.close()
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def apply_loras(pipe, loras: list | None) -> None:
|
|
145
|
+
"""LoRA 热切换(gen/edit 通用)。peft 后端下 adapter 是旁路模块、base 权重不被改写,
|
|
146
|
+
set_adapters 只切激活集与 scale(毫秒级);未加载过的文件在常驻 pipe 上直接 load。"""
|
|
147
|
+
global _ADAPTER_SEQ
|
|
148
|
+
import torch
|
|
149
|
+
|
|
150
|
+
req = [(os.path.abspath(l["path"]), float(l.get("scale", 1.0))) for l in (loras or [])]
|
|
151
|
+
if not req:
|
|
152
|
+
if LOADED:
|
|
153
|
+
pipe.disable_lora() # 不用 set_adapters([]):空列表语义跨版本不稳
|
|
154
|
+
return
|
|
155
|
+
by_path = {p: n for n, p in LOADED.items()}
|
|
156
|
+
missing = [p for p, _ in req if p not in by_path]
|
|
157
|
+
if missing and len(LOADED) + len(missing) > MAX_ADAPTERS:
|
|
158
|
+
pipe.unload_lora_weights()
|
|
159
|
+
LOADED.clear()
|
|
160
|
+
gc.collect()
|
|
161
|
+
torch.mps.empty_cache()
|
|
162
|
+
by_path = {}
|
|
163
|
+
missing = [p for p, _ in req]
|
|
164
|
+
for p in missing:
|
|
165
|
+
_ADAPTER_SEQ += 1
|
|
166
|
+
name = f"a{_ADAPTER_SEQ}"
|
|
167
|
+
pipe.load_lora_weights(p, adapter_name=name)
|
|
168
|
+
LOADED[name] = p
|
|
169
|
+
by_path[p] = name
|
|
170
|
+
log(f"lora loaded: {name} <- {p}")
|
|
171
|
+
pipe.enable_lora()
|
|
172
|
+
pipe.set_adapters([by_path[p] for p, _ in req], [s for _, s in req])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def apply_scheduler(pipe, lightning: bool) -> None:
|
|
176
|
+
"""Lightning 蒸馏调度器 per-job 切换/恢复(配置常量 import 自 gen.py——与 edit.py 内
|
|
177
|
+
同名定义逐字段一致,改调度参数时两处需同步)。"""
|
|
178
|
+
global CUR_LIGHTNING
|
|
179
|
+
if lightning == CUR_LIGHTNING:
|
|
180
|
+
return
|
|
181
|
+
from diffusers import FlowMatchEulerDiscreteScheduler
|
|
182
|
+
import gen as G
|
|
183
|
+
|
|
184
|
+
pipe.scheduler = FlowMatchEulerDiscreteScheduler.from_config(
|
|
185
|
+
G.LIGHTNING_SCHEDULER_CONFIG if lightning else SCHED_DEFAULT_CONFIG
|
|
186
|
+
)
|
|
187
|
+
CUR_LIGHTNING = lightning
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_job(req: dict) -> dict:
|
|
191
|
+
kind = req["kind"]
|
|
192
|
+
if kind == "gen":
|
|
193
|
+
import gen as G
|
|
194
|
+
global GEN_SR
|
|
195
|
+
apply_loras(PIPE, req.get("loras"))
|
|
196
|
+
apply_scheduler(PIPE, bool(req.get("lightningSched")))
|
|
197
|
+
if req.get("upscaleTo") and GEN_SR is None:
|
|
198
|
+
GEN_SR = G.load_esrgan(req.get("esrganModel"))
|
|
199
|
+
return G.run(PIPE, req, sr=GEN_SR)
|
|
200
|
+
if kind == "edit":
|
|
201
|
+
import edit as E
|
|
202
|
+
apply_loras(PIPE, req.get("loras"))
|
|
203
|
+
apply_scheduler(PIPE, bool(req.get("lightningSched")))
|
|
204
|
+
return E.run(PIPE, req)
|
|
205
|
+
if kind == "upscale":
|
|
206
|
+
import upscale as U
|
|
207
|
+
global SR
|
|
208
|
+
path = req.get("model") or resolve_esrgan_path()
|
|
209
|
+
if SR is None or SR[0] != path:
|
|
210
|
+
SR = (path, U.load_model(path))
|
|
211
|
+
return U.run(SR[1], req)
|
|
212
|
+
raise ValueError(f"未知 kind: {kind}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def worker(mode: str, snapshot: str) -> None:
|
|
216
|
+
"""唯一碰 torch 的线程:加载模型 → 串行消费任务队列。"""
|
|
217
|
+
global PIPE, SCHED_DEFAULT_CONFIG, STATE
|
|
218
|
+
import torch # —— 重 import 从这里才开始(bind 已完成,客户端在 backlog 排队)——
|
|
219
|
+
|
|
220
|
+
STATE["state"] = "loading"
|
|
221
|
+
write_status()
|
|
222
|
+
log(f"loading pipeline mode={mode} snapshot={snapshot}")
|
|
223
|
+
t0 = time.time()
|
|
224
|
+
if mode == "gen":
|
|
225
|
+
import gen as G
|
|
226
|
+
PIPE = G.load_pipe(snapshot)
|
|
227
|
+
else:
|
|
228
|
+
import edit as E
|
|
229
|
+
PIPE = E.load_pipe(snapshot)
|
|
230
|
+
SCHED_DEFAULT_CONFIG = dict(PIPE.scheduler.config)
|
|
231
|
+
STATE.update(state="ready", startedAt=time.time(), lastJobAt=time.time())
|
|
232
|
+
write_status()
|
|
233
|
+
log(f"ready(加载 {time.time() - t0:.1f}s)")
|
|
234
|
+
|
|
235
|
+
while True:
|
|
236
|
+
conn, req = JOBS.get()
|
|
237
|
+
STATE.update(busy=True, state="busy", queue=JOBS.qsize())
|
|
238
|
+
write_status()
|
|
239
|
+
log(f"job #{STATE['jobs'] + 1} kind={req['kind']} out={req.get('out') or req.get('in')}")
|
|
240
|
+
t0 = time.time()
|
|
241
|
+
try:
|
|
242
|
+
with contextlib.redirect_stdout(ClientWriter(conn)), contextlib.redirect_stderr(
|
|
243
|
+
ClientWriter(conn)
|
|
244
|
+
):
|
|
245
|
+
result = run_job(req)
|
|
246
|
+
send(conn, {"t": "done", "ok": True, "result": result})
|
|
247
|
+
log(f"job done in {time.time() - t0:.1f}s")
|
|
248
|
+
except Exception as e:
|
|
249
|
+
tb = traceback.format_exc()[-2000:]
|
|
250
|
+
log(f"job FAILED in {time.time() - t0:.1f}s: {e}\n{tb}")
|
|
251
|
+
send(conn, {"t": "done", "ok": False, "error": str(e), "tb": tb})
|
|
252
|
+
finally:
|
|
253
|
+
try:
|
|
254
|
+
conn.close()
|
|
255
|
+
except OSError:
|
|
256
|
+
pass
|
|
257
|
+
STATE.update(busy=False, state="ready", jobs=STATE["jobs"] + 1, lastJobAt=time.time())
|
|
258
|
+
write_status()
|
|
259
|
+
gc.collect()
|
|
260
|
+
torch.mps.empty_cache() # 保权重,释放激活/allocator 缓存块
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def shutdown_and_exit() -> None:
|
|
264
|
+
"""统一退出路径。unlink 先行——dispatcher 主线程在 EXITING.set() 后即退出 main 并冻结
|
|
265
|
+
本 daemon 线程,清理动作若排在 sleep 后会被竞态跳过(2026-08-30 实录:idle 自退残留 socket)。
|
|
266
|
+
unlink 后新 connect 得 ENOENT → 客户端冷路径,行为等价于关 listen。"""
|
|
267
|
+
EXITING.set()
|
|
268
|
+
for p in (SOCK_PATH, STATUS_PATH):
|
|
269
|
+
try:
|
|
270
|
+
os.unlink(p)
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
if SRV:
|
|
274
|
+
try:
|
|
275
|
+
SRV.close()
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
time.sleep(0.3) # 让 in-flight 客户端收到断连(而非挂到进程树消亡)
|
|
279
|
+
log("exit")
|
|
280
|
+
os._exit(0)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def watchdog(idle_timeout: float) -> None:
|
|
284
|
+
"""空闲自退(默认 30min):ready 且队列空且距上一任务超时。"""
|
|
285
|
+
while not EXITING.wait(15):
|
|
286
|
+
if STATE["busy"] or not JOBS.empty() or STATE["lastJobAt"] is None:
|
|
287
|
+
continue
|
|
288
|
+
idle = time.time() - STATE["lastJobAt"]
|
|
289
|
+
if idle > idle_timeout:
|
|
290
|
+
log(f"idle {idle:.0f}s > {idle_timeout:.0f}s,自退")
|
|
291
|
+
shutdown_and_exit()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def on_term(signum, frame) -> None:
|
|
295
|
+
log(f"signal {signum}")
|
|
296
|
+
shutdown_and_exit()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def bind_with_stale_probe(path: str) -> socket.socket:
|
|
300
|
+
"""socket 文件已存在:probe connect 通=已有活实例(exit 3);拒=陈旧残留(-9 遗物)清掉重绑。"""
|
|
301
|
+
if os.path.exists(path):
|
|
302
|
+
probe = socket.socket(socket.AF_UNIX)
|
|
303
|
+
try:
|
|
304
|
+
probe.connect(path)
|
|
305
|
+
probe.close()
|
|
306
|
+
print(f"已有一个 {path} 的活实例在运行", file=sys.stderr)
|
|
307
|
+
sys.exit(3)
|
|
308
|
+
except OSError:
|
|
309
|
+
os.unlink(path)
|
|
310
|
+
s = socket.socket(socket.AF_UNIX)
|
|
311
|
+
s.bind(path)
|
|
312
|
+
return s
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main() -> None:
|
|
316
|
+
global LOG, SRV, SOCK_PATH, STATUS_PATH, STATE
|
|
317
|
+
ap = argparse.ArgumentParser()
|
|
318
|
+
ap.add_argument("--mode", choices=["gen", "edit"], required=True)
|
|
319
|
+
ap.add_argument("--socket", required=True)
|
|
320
|
+
ap.add_argument("--status", required=True)
|
|
321
|
+
ap.add_argument("--log", required=True)
|
|
322
|
+
ap.add_argument("--snapshot", required=True)
|
|
323
|
+
ap.add_argument("--idle-timeout", type=float, default=1800)
|
|
324
|
+
a = ap.parse_args()
|
|
325
|
+
|
|
326
|
+
os.makedirs(os.path.dirname(a.socket), exist_ok=True)
|
|
327
|
+
LOG = open(a.log, "a", buffering=1)
|
|
328
|
+
sys.stdout = sys.stderr = LOG # daemon 自身输出全落日志(任务输出由 ClientWriter 定向)
|
|
329
|
+
SOCK_PATH, STATUS_PATH = a.socket, a.status
|
|
330
|
+
STATE.update(mode=a.mode, startedAt=time.time(), snapshot=a.snapshot)
|
|
331
|
+
write_status()
|
|
332
|
+
log(f"boot pid={os.getpid()} mode={a.mode} idle_timeout={a.idle_timeout}s")
|
|
333
|
+
|
|
334
|
+
srv = bind_with_stale_probe(a.socket)
|
|
335
|
+
srv.listen(128)
|
|
336
|
+
SRV = srv
|
|
337
|
+
signal.signal(signal.SIGTERM, on_term)
|
|
338
|
+
signal.signal(signal.SIGINT, on_term)
|
|
339
|
+
|
|
340
|
+
threading.Thread(target=worker, args=(a.mode, a.snapshot), daemon=True).start()
|
|
341
|
+
threading.Thread(target=watchdog, args=(a.idle_timeout,), daemon=True).start()
|
|
342
|
+
|
|
343
|
+
while not EXITING.is_set(): # 主线程 = dispatcher
|
|
344
|
+
try:
|
|
345
|
+
conn, _ = srv.accept()
|
|
346
|
+
except OSError:
|
|
347
|
+
break
|
|
348
|
+
threading.Thread(target=handle_conn, args=(conn,), daemon=True).start()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == "__main__":
|
|
352
|
+
main()
|