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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/README.zh-CN.md +88 -0
  4. package/package.json +54 -0
  5. package/packages/cli/dist/index.d.ts +2 -0
  6. package/packages/cli/dist/index.js +171 -0
  7. package/packages/cli/dist/index.js.map +1 -0
  8. package/packages/doubao/dist/cli.d.ts +38 -0
  9. package/packages/doubao/dist/cli.d.ts.map +1 -0
  10. package/packages/doubao/dist/cli.js +206 -0
  11. package/packages/gcli/dist/cli.d.ts +465 -0
  12. package/packages/gcli/dist/cli.js +2017 -0
  13. package/packages/gcli/dist/cli.js.map +1 -0
  14. package/packages/lmedia/dist/index.d.ts +1 -0
  15. package/packages/lmedia/dist/index.js +1594 -0
  16. package/packages/lmedia/python/edit.py +107 -0
  17. package/packages/lmedia/python/esrgan_path.py +16 -0
  18. package/packages/lmedia/python/gen.py +131 -0
  19. package/packages/lmedia/python/serve.py +352 -0
  20. package/packages/lmedia/python/sfx.py +527 -0
  21. package/packages/lmedia/python/teacache.py +255 -0
  22. package/packages/lmedia/python/upscale.py +41 -0
  23. package/packages/minimax/dist/cli.d.ts +51 -0
  24. package/packages/minimax/dist/cli.js +307 -0
  25. package/packages/minimax/dist/cli.js.map +1 -0
  26. package/packages/minimax/dist/client.d.ts +20 -0
  27. package/packages/minimax/dist/client.js +55 -0
  28. package/packages/minimax/dist/client.js.map +1 -0
  29. package/packages/minimax/dist/tts.d.ts +33 -0
  30. package/packages/minimax/dist/tts.js +64 -0
  31. package/packages/minimax/dist/tts.js.map +1 -0
  32. package/packages/minimax/dist/validate.d.ts +29 -0
  33. package/packages/minimax/dist/validate.js +122 -0
  34. package/packages/minimax/dist/validate.js.map +1 -0
  35. package/packages/minimax/dist/voice-clone.d.ts +17 -0
  36. package/packages/minimax/dist/voice-clone.js +47 -0
  37. package/packages/minimax/dist/voice-clone.js.map +1 -0
  38. package/packages/minimax/dist/voices.d.ts +17 -0
  39. package/packages/minimax/dist/voices.js +20 -0
  40. package/packages/minimax/dist/voices.js.map +1 -0
  41. package/packages/qwen/dist/index.d.ts +1 -0
  42. 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()