dsh-h3-image 0.2.6 → 0.2.8
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/README.md +4 -4
- package/assets/stage_weights.py +226 -0
- package/lib/checks.js +17 -1
- package/lib/index.js +2 -1
- package/lib/jobs.js +8 -0
- package/lib/setup.js +46 -11
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
| 工具 | 干什么用 |
|
|
16
16
|
| --- | --- |
|
|
17
|
-
| `h3_status` | 只读体检:平台、内存、venv
|
|
17
|
+
| `h3_status` | 只读体检:平台、内存、venv、引擎版本、权重、文本编码器、磁盘逐项检查,缺什么标红并告诉你怎么修。 |
|
|
18
18
|
| `h3_setup` | 安装向导:`check → venv → engine → license → weights → smoke` 一步步走,中断了重跑接着来。耗时长的步骤(装引擎、下 59 GB 权重)放后台跑,提交后马上返回 `job_id`,用 `action=poll` 看进度;下到一半断了,重跑从 `.part` 接着下,下完的分片不会重下;任务被杀会如实报 `interrupted`,不谎报"完成"。`action=license` 展示许可协议,只能由你自己确认接受;`action=reset` 清掉本地状态记录。 |
|
|
19
19
|
| `h3_image` | 文生图。同一时间只跑一张,第二张会立刻收到 `busy`;出图前先校验 PNG 结构,确认是完整图才报成功,图片存到你的会话工作区。 |
|
|
20
20
|
|
|
@@ -69,9 +69,9 @@ npm run release # 门禁 + pack + tarball 扫描
|
|
|
69
69
|
|
|
70
70
|
> **Unofficial**: a third-party community tool, not affiliated with or endorsed by MiniMax; "MiniMax-H3-Image" is referenced descriptively only. Weights are provided under the MiniMax Community License, whose authorized territory **excludes the EU, UK, South Korea and the US** — verify you may lawfully use the model in your jurisdiction. Full disclaimer: [docs/DISCLAIMER.md](docs/DISCLAIMER.md).
|
|
71
71
|
|
|
72
|
-
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin bundle that runs the open-source **MiniMax H3-Image** text-to-image model **entirely on your own Apple Silicon Mac**, through the [MLX](https://github.com/ml-explore/mlx) engine. No API keys, no accounts, no per-image fees, no
|
|
72
|
+
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin bundle that runs the open-source **MiniMax H3-Image** text-to-image model **entirely on your own Apple Silicon Mac**, through the [MLX](https://github.com/ml-explore/mlx) engine. No API keys, no accounts, no per-image fees, no network access during generation.
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
Usage is three steps: run `h3_status` to see whether your machine qualifies, let the `h3_setup` wizard install the engine and the weights (you confirm the MiniMax license yourself before anything downloads), then generate images with `h3_image`.
|
|
75
75
|
|
|
76
76
|
### Tools
|
|
77
77
|
|
|
@@ -104,7 +104,7 @@ Interrupted steps resume: a killed download continues from its `.part`, and fini
|
|
|
104
104
|
|
|
105
105
|
### Licenses — read before downloading weights
|
|
106
106
|
|
|
107
|
-
This plugin's **code** is MIT. It does not ship, re-host or re-distribute the model — the model and its license terms have nothing to do with this plugin, which is only a downloader and runner with a license gate.
|
|
107
|
+
This plugin's **code** is MIT. It does not ship, re-host or re-distribute the model — the model and its license terms have nothing to do with this plugin, which is only a downloader and runner with a license gate. The setup wizard downloads the weights from the official Hugging Face repositories at pinned versions, and before any byte is fetched it **shows the key points of both agreements with direct links to their full texts** (weights: MiniMax H3 Image Community License; quantized text encoder: MiniMax H3 Community License, each from its own upstream repo) and **names the territorial exclusions (EU/UK/KR/US)**; the original agreement files land on your disk inside the model directory. You accept them yourself; the plugin never accepts on your behalf. The upstream engine is Apache-2.0. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
|
|
108
108
|
|
|
109
109
|
### Install (DSH)
|
|
110
110
|
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Weight-staging driver for dsh-h3-image (bundled asset, not engine code).
|
|
3
|
+
|
|
4
|
+
Why this exists: the pinned engine's fetch_h3_image.py hardcodes
|
|
5
|
+
https://huggingface.co and has no mirror switch, and patching the pinned
|
|
6
|
+
engine is out of scope (design D1). So instead of touching the engine, this
|
|
7
|
+
driver places the EXACT pinned file set (CORE_FILES + the transformer shards,
|
|
8
|
+
read live from the engine script — never re-listed or re-hashed here) into
|
|
9
|
+
the engine's own layout ahead of time. The engine's fetch contract then does
|
|
10
|
+
the rest: a file that already exists with the right size and sha256 is
|
|
11
|
+
returned without any network access, so the following engine run is instant
|
|
12
|
+
for the main weights while still converting shard by shard as usual.
|
|
13
|
+
|
|
14
|
+
Endpoint honesty: every endpoint is tried DIRECT first (proxies explicitly
|
|
15
|
+
disabled — a proxy download would silently spend the user's proxy data). Only
|
|
16
|
+
after every endpoint failed direct does one final round run through the system
|
|
17
|
+
proxy, announced by its own line before it starts. Redirects (301/302/303/
|
|
18
|
+
307/308) are followed by hand because urllib does not follow 308, which is
|
|
19
|
+
exactly what a mirror answers for every file; the Range header is kept across
|
|
20
|
+
hops so a resumed download stays resumed through a 308.
|
|
21
|
+
|
|
22
|
+
File-list truth: imported from the engine script (--engine-script), so a pin
|
|
23
|
+
bump needs no change here.
|
|
24
|
+
|
|
25
|
+
Progress contract (parsed by lib/jobs.js, all flushed):
|
|
26
|
+
[stage n/N] fetching <name> via <host>
|
|
27
|
+
[stage n/N] placed <name>
|
|
28
|
+
[stage n/N] skipped <name> (already present)
|
|
29
|
+
[stage n/N] switched endpoint: <host>
|
|
30
|
+
stage: retrying via system proxy (may count against proxy data)
|
|
31
|
+
STAGE OK (terminal, stdout)
|
|
32
|
+
STAGE FAIL <rel> (terminal, stderr, exit 1)
|
|
33
|
+
|
|
34
|
+
Resumability: a `.part` survives a failed attempt and resumes with a Range
|
|
35
|
+
request; a server that ignores Range restarts the file cleanly (200 →
|
|
36
|
+
truncate). A part that fails sha256 is deleted, never renamed into place.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
python stage_weights.py --dir MODEL_DIR \
|
|
40
|
+
--engine-script .../scripts/fetch_h3_image.py \
|
|
41
|
+
--endpoints hf,https://mirror.example
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import argparse
|
|
47
|
+
import hashlib
|
|
48
|
+
import sys
|
|
49
|
+
import urllib.error
|
|
50
|
+
import urllib.parse
|
|
51
|
+
import urllib.request
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
TIMEOUT = 60 # seconds without data on any single read — same as the engine fetch
|
|
55
|
+
REDIRECT_CODES = (301, 302, 303, 307, 308)
|
|
56
|
+
HF = "https://huggingface.co"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
60
|
+
"""Neutralise urllib's built-in redirect handling (it ignores 308); the
|
|
61
|
+
loop in open_following() follows every redirect code itself."""
|
|
62
|
+
|
|
63
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sha256_of(path: Path) -> str:
|
|
68
|
+
h = hashlib.sha256()
|
|
69
|
+
with path.open("rb") as fh:
|
|
70
|
+
for chunk in iter(lambda: fh.read(1 << 22), b""):
|
|
71
|
+
h.update(chunk)
|
|
72
|
+
return h.hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_opener(direct: bool):
|
|
76
|
+
"""direct=True → ProxyHandler({}) (never the user's proxy); direct=False
|
|
77
|
+
→ the default proxy handler, which honours the system proxy settings."""
|
|
78
|
+
if direct:
|
|
79
|
+
return urllib.request.build_opener(_NoRedirect(), urllib.request.ProxyHandler({}))
|
|
80
|
+
return urllib.request.build_opener(_NoRedirect())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def open_following(opener, url: str, headers: dict, max_redirects: int = 10):
|
|
84
|
+
"""GET url, following 301/302/303/307/308 by hand. Returns the live
|
|
85
|
+
response; the caller streams from it and it closes with the caller's
|
|
86
|
+
`with` block."""
|
|
87
|
+
for _ in range(max_redirects + 1):
|
|
88
|
+
req = urllib.request.Request(url, headers=headers)
|
|
89
|
+
try:
|
|
90
|
+
return opener.open(req, timeout=TIMEOUT)
|
|
91
|
+
except urllib.error.HTTPError as err:
|
|
92
|
+
location = err.headers.get("Location") if err.headers is not None else None
|
|
93
|
+
err.close()
|
|
94
|
+
if err.code in REDIRECT_CODES and location:
|
|
95
|
+
url = urllib.parse.urljoin(url, location)
|
|
96
|
+
continue
|
|
97
|
+
raise
|
|
98
|
+
raise RuntimeError(f"more than {max_redirects} redirects for {url}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def attempt(opener, url: str, dest: Path, part: Path, size: int, sha, name: str,
|
|
102
|
+
index: int, total: int, first: bool) -> None:
|
|
103
|
+
"""One endpoint, one file: stream into .part (resuming when the server
|
|
104
|
+
honours Range), then verify and rename. Any raise = this endpoint failed
|
|
105
|
+
for this file; the partial .part stays on disk for the next attempt."""
|
|
106
|
+
host = urllib.parse.urlsplit(url).hostname or url
|
|
107
|
+
if not first:
|
|
108
|
+
print(f"[stage {index}/{total}] switched endpoint: {host}", flush=True)
|
|
109
|
+
print(f"[stage {index}/{total}] fetching {name} via {host}", flush=True)
|
|
110
|
+
have = part.stat().st_size if part.exists() else 0
|
|
111
|
+
if have > size:
|
|
112
|
+
part.unlink()
|
|
113
|
+
have = 0
|
|
114
|
+
headers = {"Range": f"bytes={have}-"} if have else {}
|
|
115
|
+
with open_following(opener, url, headers) as resp:
|
|
116
|
+
resume = bool(have) and getattr(resp, "status", 200) == 206
|
|
117
|
+
with part.open("ab" if resume else "wb") as out:
|
|
118
|
+
while True:
|
|
119
|
+
block = resp.read(1 << 22)
|
|
120
|
+
if not block:
|
|
121
|
+
break
|
|
122
|
+
out.write(block)
|
|
123
|
+
got = part.stat().st_size if part.exists() else 0
|
|
124
|
+
if got != size:
|
|
125
|
+
if got > size:
|
|
126
|
+
part.unlink(missing_ok=True) # a corrupt oversize part can never resume
|
|
127
|
+
raise RuntimeError(f"expected {size} bytes, got {got}")
|
|
128
|
+
if sha is not None and sha256_of(part) != sha:
|
|
129
|
+
part.unlink(missing_ok=True) # wrong content: never stage it, never resume it
|
|
130
|
+
raise RuntimeError("sha256 mismatch")
|
|
131
|
+
part.rename(dest)
|
|
132
|
+
print(f"[stage {index}/{total}] placed {name}", flush=True)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
|
|
136
|
+
middle: str, index: int, total: int) -> bool:
|
|
137
|
+
"""Place one pinned file. Returns True on placed/skipped, False after every
|
|
138
|
+
endpoint failed in both the direct and the system-proxy round."""
|
|
139
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
if dest.exists() and dest.stat().st_size == size and (sha is None or sha256_of(dest) == sha):
|
|
141
|
+
print(f"[stage {index}/{total}] skipped {name} (already present)", flush=True)
|
|
142
|
+
return True
|
|
143
|
+
part = dest.with_name(dest.name + ".part")
|
|
144
|
+
if part.exists() and part.stat().st_size == size:
|
|
145
|
+
# A part that is already complete goes straight to verification
|
|
146
|
+
# (same shortcut the engine fetch takes), skipping the network.
|
|
147
|
+
if sha is None or sha256_of(part) == sha:
|
|
148
|
+
part.rename(dest)
|
|
149
|
+
print(f"[stage {index}/{total}] placed {name}", flush=True)
|
|
150
|
+
return True
|
|
151
|
+
part.unlink()
|
|
152
|
+
|
|
153
|
+
url_of = lambda endpoint: f"{endpoint.rstrip('/')}/{middle}/{rel}" # noqa: E731
|
|
154
|
+
last_error = None
|
|
155
|
+
attempts = 0
|
|
156
|
+
direct = build_opener(direct=True)
|
|
157
|
+
for endpoint in endpoints:
|
|
158
|
+
try:
|
|
159
|
+
attempt(direct, url_of(endpoint), dest, part, size, sha, name, index, total, first=attempts == 0)
|
|
160
|
+
return True
|
|
161
|
+
except Exception as error: # noqa: BLE001 — any failure means: next endpoint
|
|
162
|
+
last_error = error
|
|
163
|
+
attempts += 1
|
|
164
|
+
print("stage: retrying via system proxy (may count against proxy data)", flush=True)
|
|
165
|
+
proxied = build_opener(direct=False)
|
|
166
|
+
for endpoint in endpoints:
|
|
167
|
+
try:
|
|
168
|
+
attempt(proxied, url_of(endpoint), dest, part, size, sha, name, index, total, first=attempts == 0)
|
|
169
|
+
return True
|
|
170
|
+
except Exception as error: # noqa: BLE001
|
|
171
|
+
last_error = error
|
|
172
|
+
attempts += 1
|
|
173
|
+
print(f"stage: {name}: {last_error}", file=sys.stderr, flush=True)
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv=None) -> int:
|
|
178
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
179
|
+
ap.add_argument("--dir", type=Path, required=True, help="model root (the engine fetch's --dir)")
|
|
180
|
+
ap.add_argument("--engine-script", type=Path, required=True, help="absolute path to the engine's fetch_h3_image.py")
|
|
181
|
+
ap.add_argument("--endpoints", required=True, help="comma-separated priority list; 'hf' or https://host[/prefix] per entry")
|
|
182
|
+
args = ap.parse_args(argv)
|
|
183
|
+
|
|
184
|
+
# The engine script's own directory goes on sys.path so `import
|
|
185
|
+
# fetch_h3_image` (and its sibling import of fetch_ph3_image) resolves.
|
|
186
|
+
# Importing it only reads constants — no network, no side effects.
|
|
187
|
+
sys.path.insert(0, str(args.engine_script.resolve().parent))
|
|
188
|
+
import fetch_h3_image as engine # noqa: E402
|
|
189
|
+
|
|
190
|
+
middle = f"{engine.REPO}/resolve/{engine.REVISION}"
|
|
191
|
+
|
|
192
|
+
endpoints = []
|
|
193
|
+
for raw in args.endpoints.split(","):
|
|
194
|
+
raw = raw.strip().rstrip("/")
|
|
195
|
+
if raw:
|
|
196
|
+
endpoints.append(HF if raw == "hf" else raw)
|
|
197
|
+
if not endpoints:
|
|
198
|
+
print("stage: --endpoints resolved to nothing", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
|
|
201
|
+
# Layout truth = the engine's own fetch_h3_image.main: CORE_FILES go to
|
|
202
|
+
# root/<rel> (root/source/<rel> for transformer/), shards to
|
|
203
|
+
# root/source/transformer/<name> via the engine's SHARD_FILE template.
|
|
204
|
+
shard_file = getattr(engine, "SHARD_FILE", "transformer/{name}")
|
|
205
|
+
items = []
|
|
206
|
+
for rel, (size, sha) in engine.CORE_FILES.items():
|
|
207
|
+
dest = args.dir / rel if not rel.startswith("transformer/") else args.dir / "source" / rel
|
|
208
|
+
items.append((rel, rel, dest, size, sha))
|
|
209
|
+
for shard_name in sorted(engine.SHARDS):
|
|
210
|
+
size, sha = engine.SHARDS[shard_name]
|
|
211
|
+
rel = shard_file.format(name=shard_name)
|
|
212
|
+
items.append((shard_name, rel, args.dir / "source" / rel, size, sha))
|
|
213
|
+
|
|
214
|
+
total = len(items)
|
|
215
|
+
for index, (name, rel, dest, size, sha) in enumerate(items, 1):
|
|
216
|
+
ok = stage_file(name=name, rel=rel, dest=dest, size=size, sha=sha,
|
|
217
|
+
endpoints=endpoints, middle=middle, index=index, total=total)
|
|
218
|
+
if not ok:
|
|
219
|
+
print(f"STAGE FAIL {rel}", file=sys.stderr, flush=True)
|
|
220
|
+
return 1
|
|
221
|
+
print("STAGE OK", flush=True)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
raise SystemExit(main())
|
package/lib/checks.js
CHANGED
|
@@ -313,6 +313,18 @@ export function checkDisk({ freeGB, modelDirName }) {
|
|
|
313
313
|
// Full status run (used by the h3_status tool).
|
|
314
314
|
// ---------------------------------------------------------------------------
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Mirror normalization (single truth for the config surface):
|
|
318
|
+
* "hf" | undefined | anything invalid → "hf"
|
|
319
|
+
* "hf-mirror" (legacy literal) → "https://hf-mirror.com"
|
|
320
|
+
* any other https:// URL → kept as-is (custom endpoint)
|
|
321
|
+
*/
|
|
322
|
+
export function normalizeMirror(value) {
|
|
323
|
+
if (value === "hf-mirror") return "https://hf-mirror.com";
|
|
324
|
+
if (typeof value === "string" && value.startsWith("https://") && !/\s/.test(value)) return value;
|
|
325
|
+
return "hf";
|
|
326
|
+
}
|
|
327
|
+
|
|
316
328
|
export function resolveConfig(config = {}) {
|
|
317
329
|
const home = homedir();
|
|
318
330
|
// With an explicit model_dir, sibling defaults hang off its parent (the
|
|
@@ -337,7 +349,11 @@ export function resolveConfig(config = {}) {
|
|
|
337
349
|
venv_dir: config.venv_dir ?? join(root, "venv"),
|
|
338
350
|
text_dir,
|
|
339
351
|
text_config,
|
|
340
|
-
|
|
352
|
+
// Mirror: "hf" (default) or a custom endpoint. The legacy literal
|
|
353
|
+
// "hf-mirror" normalizes to its URL; any https:// URL is a valid custom
|
|
354
|
+
// endpoint (staged main weights + TE HF_ENDPOINT both use it). Anything
|
|
355
|
+
// else falls back to "hf" — a typo must never disable the default path.
|
|
356
|
+
mirror: normalizeMirror(config.mirror),
|
|
341
357
|
// Reuse-probe scan roots (absolute or $HOME-relative). Default is the
|
|
342
358
|
// one known asset location; an explicit [] forces a true bare-metal
|
|
343
359
|
// simulation (no candidates → full download + verification path).
|
package/lib/index.js
CHANGED
|
@@ -106,7 +106,8 @@ function formatSetup(value) {
|
|
|
106
106
|
lines.push(`h3_setup poll [${value.kind}/${value.state}] job ${value.job_id}`);
|
|
107
107
|
const p = value.progress ?? {};
|
|
108
108
|
if (value.kind === "weights") {
|
|
109
|
-
|
|
109
|
+
const staged = p.stage_total ? `staged ${p.stage_done}/${p.stage_total}; ` : "";
|
|
110
|
+
lines.push(`${staged}shards ${p.shard_done ?? 0}/${p.shard_total ?? 12} converted${p.current ? ` (fetching #${p.current})` : ""}; text encoder ${p.te_done ?? 0}/${p.te_total ?? 9}`);
|
|
110
111
|
} else if (p.stage) {
|
|
111
112
|
lines.push(`stage: ${p.stage}`);
|
|
112
113
|
}
|
package/lib/jobs.js
CHANGED
|
@@ -144,6 +144,7 @@ export async function tailLog(jDir, id, maxBytes = 2000, readFileP = readFile) {
|
|
|
144
144
|
* `PACK OK -> ...` (engine fetch, terminal)
|
|
145
145
|
* `[te n/9] fetched <name>` / `[te n/9] reused <name>` (our TE driver)
|
|
146
146
|
* `TE OK` (TE driver, terminal)
|
|
147
|
+
* `[stage n/N] placed <name>` (our staging driver)
|
|
147
148
|
* `== <label> ==` (supervisor stage marker)
|
|
148
149
|
*/
|
|
149
150
|
export function parseProgress(logTail) {
|
|
@@ -161,6 +162,13 @@ export function parseProgress(logTail) {
|
|
|
161
162
|
out.te_total = Math.max(out.te_total, +m[2]);
|
|
162
163
|
out.te_done = Math.max(out.te_done, +m[1]);
|
|
163
164
|
}
|
|
165
|
+
// Staging only reports a total when a mirror run actually staged files;
|
|
166
|
+
// absent lines leave stage_total undefined so the renderer can skip it.
|
|
167
|
+
const stageLines = [...logTail.matchAll(/^\[stage (\d+)\/(\d+)\] (placed|skipped)/gm)];
|
|
168
|
+
for (const m of stageLines) {
|
|
169
|
+
out.stage_total = Math.max(out.stage_total ?? 0, +m[2]);
|
|
170
|
+
out.stage_done = Math.max(out.stage_done ?? 0, +m[1]);
|
|
171
|
+
}
|
|
164
172
|
out.pack_ok = /^PACK OK/m.test(logTail);
|
|
165
173
|
out.te_ok = /^TE OK/m.test(logTail);
|
|
166
174
|
return out;
|
package/lib/setup.js
CHANGED
|
@@ -23,11 +23,14 @@
|
|
|
23
23
|
* symlink, never by copy — only on an exact digest match. No match → download
|
|
24
24
|
* from the pinned revision. A size match alone never authorizes reuse.
|
|
25
25
|
*
|
|
26
|
-
* Mirror honesty (documented in README):
|
|
27
|
-
*
|
|
28
|
-
* weights
|
|
29
|
-
*
|
|
30
|
-
*
|
|
26
|
+
* Mirror honesty (documented in README): a non-default `mirror` covers BOTH
|
|
27
|
+
* download faces without touching the pinned engine (design D1). The main
|
|
28
|
+
* weights are staged ahead of the engine fetch by assets/stage_weights.py,
|
|
29
|
+
* which places the exact pinned file set into the engine's own layout from
|
|
30
|
+
* the chosen endpoint; the engine fetch then skips every placed file (its
|
|
31
|
+
* contract: present + right size + right sha256 → no network). The
|
|
32
|
+
* text-encoder download honours HF_ENDPOINT (hf_hub_download). The default
|
|
33
|
+
* `hf` carries no stage step and no env override at all.
|
|
31
34
|
*/
|
|
32
35
|
import { execFile } from "node:child_process";
|
|
33
36
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
@@ -192,9 +195,39 @@ function teDriverPath() {
|
|
|
192
195
|
return join(import.meta.dirname, "..", "assets", "te_setup.py");
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
|
|
198
|
+
function stageDriverPath() {
|
|
199
|
+
return join(import.meta.dirname, "..", "assets", "stage_weights.py");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Legacy literal kept working for old profile configs; anything else that
|
|
203
|
+
* is not "hf" is treated as a custom endpoint URL. */
|
|
204
|
+
const HF_MIRROR_URL = "https://hf-mirror.com";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Engine fetch chain for the weights job (resumable by the engine itself).
|
|
208
|
+
* `mirror` "hf" (default) = plain engine fetch + TE, zero env. Any other
|
|
209
|
+
* value (resolveConfig normalizes "hf-mirror" to its URL) = a custom
|
|
210
|
+
* endpoint: a `stage` command runs FIRST and places every pinned main-weight
|
|
211
|
+
* file from `<mirror>,hf` (mirror first — a user who configured one means
|
|
212
|
+
* it; hf as fallback), so the engine fetch that follows skips them all;
|
|
213
|
+
* the TE command points HF_ENDPOINT at the same endpoint.
|
|
214
|
+
*/
|
|
196
215
|
export function buildWeightsCommands({ resolved, venvBin, mirror }) {
|
|
197
|
-
|
|
216
|
+
const endpoint = mirror === "hf-mirror" ? HF_MIRROR_URL : mirror;
|
|
217
|
+
const commands = [];
|
|
218
|
+
if (mirror !== "hf") {
|
|
219
|
+
commands.push({
|
|
220
|
+
label: "stage",
|
|
221
|
+
argv: [
|
|
222
|
+
venvBin,
|
|
223
|
+
stageDriverPath(),
|
|
224
|
+
"--dir", resolved.model_dir,
|
|
225
|
+
"--engine-script", join(resolved.engine_dir, "scripts", "fetch_h3_image.py"),
|
|
226
|
+
"--endpoints", `${endpoint},hf`,
|
|
227
|
+
],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
commands.push(
|
|
198
231
|
{
|
|
199
232
|
label: "weights-fetch",
|
|
200
233
|
argv: [venvBin, join(resolved.engine_dir, "scripts", "fetch_h3_image.py"), "--dir", resolved.model_dir, "--accepted"],
|
|
@@ -217,11 +250,13 @@ export function buildWeightsCommands({ resolved, venvBin, mirror }) {
|
|
|
217
250
|
"--meta-files", UPSTREAM_META.files.join(","),
|
|
218
251
|
"--candidates-file", join(resolved.model_dir, "h3-jobs", "reuse-candidates.txt"),
|
|
219
252
|
],
|
|
220
|
-
//
|
|
221
|
-
// HF_ENDPOINT); the default carries no env
|
|
222
|
-
|
|
253
|
+
// A custom endpoint only ever touches THIS command's env
|
|
254
|
+
// (hf_hub_download honours HF_ENDPOINT); the default carries no env
|
|
255
|
+
// override at all.
|
|
256
|
+
...(mirror === "hf" ? {} : { env: { HF_ENDPOINT: endpoint } }),
|
|
223
257
|
},
|
|
224
|
-
|
|
258
|
+
);
|
|
259
|
+
return commands;
|
|
225
260
|
}
|
|
226
261
|
|
|
227
262
|
/** Smoke chain: one 1 MP image into the job area, metrics beside it. */
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "H3 Image (local MLX)",
|
|
4
|
-
"description": "Unofficial third-party plugin, not affiliated with MiniMax. On Apple Silicon, fully local text-to-image with the open-source MiniMax H3-Image (MLX engine). A one-time setup wizard downloads a fixed version of the engine and weights to your machine (the MiniMax community license and its region terms are shown before any download; you accept it yourself), then generation runs offline — no keys, no accounts, no network access. Long setup steps are background jobs that survive restarts and resume where they stopped. Requires Apple Silicon with 48 GB+ unified memory (32 GB is not guaranteed) and ~60 GB free disk during setup; Intel Macs and other platforms are declined up front. Current version (0.2.
|
|
4
|
+
"description": "Unofficial third-party plugin, not affiliated with MiniMax. On Apple Silicon, fully local text-to-image with the open-source MiniMax H3-Image (MLX engine). A one-time setup wizard downloads a fixed version of the engine and weights to your machine (the MiniMax community license and its region terms are shown before any download; you accept it yourself), then generation runs offline — no keys, no accounts, no network access. Long setup steps are background jobs that survive restarts and resume where they stopped. Requires Apple Silicon with 48 GB+ unified memory (32 GB is not guaranteed) and ~60 GB free disk during setup; Intel Macs and other platforms are declined up front. Current version (0.2.8): read-only environment check, setup wizard, and local generation (the community license must be explicitly accepted by you before the first image)."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "H3 本地生图(MLX)",
|
|
4
|
-
"description": "非官方第三方插件,与 MiniMax 无关联。在 Apple Silicon 上用开源 MiniMax H3-Image(MLX 引擎)做完全本地文生图。安装向导一次性把固定版本的引擎与权重下载到本机(下载前展示 MiniMax 社区许可及地域条款,由你本人确认接受);之后生图全程离线——不用密钥、不用账号、不联网。安装中的长步骤是后台任务,DSH 重启不丢、断点续传。需 Apple Silicon、48GB 及以上统一内存(32GB 不保证)、安装期约 60GB 可用磁盘;Intel Mac 与其他平台在检查阶段即明确拒绝。当前版本(0.2.
|
|
4
|
+
"description": "非官方第三方插件,与 MiniMax 无关联。在 Apple Silicon 上用开源 MiniMax H3-Image(MLX 引擎)做完全本地文生图。安装向导一次性把固定版本的引擎与权重下载到本机(下载前展示 MiniMax 社区许可及地域条款,由你本人确认接受);之后生图全程离线——不用密钥、不用账号、不联网。安装中的长步骤是后台任务,DSH 重启不丢、断点续传。需 Apple Silicon、48GB 及以上统一内存(32GB 不保证)、安装期约 60GB 可用磁盘;Intel Mac 与其他平台在检查阶段即明确拒绝。当前版本(0.2.8):只读环境体检 + 安装向导 + 本地产图(社区许可需你本人确认接受后才会出图)。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-h3-image",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "在 Apple Silicon 上用开源 MiniMax H3-Image(MLX 引擎)做完全本地文生图的 DeepSeek Harness 插件:一键安装向导 + 零出网本地生图。Fully local MiniMax H3-Image text-to-image on Apple Silicon MLX — a DeepSeek Harness plugin with a one-time setup wizard and
|
|
3
|
+
"version": "0.2.8",
|
|
4
|
+
"description": "在 Apple Silicon 上用开源 MiniMax H3-Image(MLX 引擎)做完全本地文生图的 DeepSeek Harness 插件:一键安装向导 + 零出网本地生图。Fully local MiniMax H3-Image text-to-image on Apple Silicon MLX — a DeepSeek Harness plugin with a one-time setup wizard and no network access at generation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
7
7
|
"scripts": {
|