dsh-h3-image 0.2.10 → 0.2.11
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 +208 -5
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
| 安装期间磁盘 | 峰值约 60 GB |
|
|
35
35
|
| 装完后磁盘 | 约 53 GB(源分片转换完会自动删掉) |
|
|
36
36
|
| 单张图内存(1 MP) | 约 27.5 GB |
|
|
37
|
-
| 每个权重分片(下载 + 转换) |
|
|
37
|
+
| 每个权重分片(下载 + 转换) | 时间跟你的网络环境和机器有关,快慢差别很大;中断可续传 |
|
|
38
38
|
| 第一次出图 | 约 30 秒 |
|
|
39
39
|
|
|
40
|
-
中断的步骤可以接着来:下到一半断了从 `.part`
|
|
40
|
+
中断的步骤可以接着来:下到一半断了从 `.part` 继续,下完的分片不会重下。大文件分 4 段并发下载;服务器不支持分段时自动回单线程,行为不变。内存那行是引擎自己报的单张图占用。
|
|
41
41
|
|
|
42
42
|
### 许可——下载权重前必读
|
|
43
43
|
|
|
@@ -101,10 +101,10 @@ Measured on an M5 Ultra with 256 GB of unified memory, from a from-scratch insta
|
|
|
101
101
|
| Disk during setup | ~60 GB at peak |
|
|
102
102
|
| Disk after setup | ~53 GB (source shards are deleted once converted) |
|
|
103
103
|
| Memory per image (1 MP) | ~27.5 GB |
|
|
104
|
-
| Per weight shard (download + convert) |
|
|
104
|
+
| Per weight shard (download + convert) | Depends on your network and machine; slow and fast setups differ a lot. Interrupted downloads resume. |
|
|
105
105
|
| First image | ~30 s |
|
|
106
106
|
|
|
107
|
-
Interrupted steps resume: a killed download continues from its `.part`, and finished shards are never re-downloaded. The memory figure is the per-image footprint the engine reports itself.
|
|
107
|
+
Interrupted steps resume: a killed download continues from its `.part`, and finished shards are never re-downloaded. Large files download in 4 parallel segments; if the server doesn't support ranged segments, it falls back to a single connection automatically. The memory figure is the per-image footprint the engine reports itself.
|
|
108
108
|
|
|
109
109
|
### Licenses — read before downloading weights
|
|
110
110
|
|
package/assets/stage_weights.py
CHANGED
|
@@ -33,6 +33,7 @@ Progress contract (parsed by lib/jobs.js, all flushed):
|
|
|
33
33
|
[stage n/N] skipped <name> (already present)
|
|
34
34
|
[stage n/N] switched endpoint: <host>
|
|
35
35
|
[stage n/N] note: <name> redirected to <host>
|
|
36
|
+
[stage n/N] seg <i>/4 <name> via <host> (segmented mode only)
|
|
36
37
|
stage: <name>: direct round failed (<err>); retrying direct once
|
|
37
38
|
stage: retrying via system proxy (may count against proxy data)
|
|
38
39
|
STAGE OK (terminal, stdout)
|
|
@@ -42,10 +43,31 @@ Resumability: a `.part` survives a failed attempt and resumes with a Range
|
|
|
42
43
|
request; a server that ignores Range restarts the file cleanly (200 →
|
|
43
44
|
truncate). A part that fails sha256 is deleted, never renamed into place.
|
|
44
45
|
|
|
46
|
+
Segmented downloads (files at or above --seg-threshold, default 64 MB): the
|
|
47
|
+
file is split into SEG_COUNT=4 byte ranges fetched concurrently by a
|
|
48
|
+
ThreadPoolExecutor (max_workers=4), each segment streaming into its own
|
|
49
|
+
`<name>.part.<i>` with a `Range: bytes=start-end` request. Every segment
|
|
50
|
+
resumes independently: a segment `.part` of `have` bytes re-requests
|
|
51
|
+
`bytes=(start+have)-`; only a 206 appends, a segment `.part` longer than its
|
|
52
|
+
segment is deleted and re-fetched from zero. Once all segments are in, they
|
|
53
|
+
are stitched into the single `.part` in order (4 MB blocks), the segment
|
|
54
|
+
parts are deleted, and the existing verification path takes over unchanged:
|
|
55
|
+
total size == size and sha256 match → rename into place; anything else → the
|
|
56
|
+
`.part` (and any segment parts) are deleted, never renamed. Disk cost: while
|
|
57
|
+
stitching, the segment parts and the assembled `.part` coexist, so the
|
|
58
|
+
temporary footprint is roughly one extra copy of the file (about 2x its size
|
|
59
|
+
during the stitch, back to ~1x once the segments are unlinked). A server that
|
|
60
|
+
answers a segment with 200 instead of 206 does not honour Range: the whole
|
|
61
|
+
segmented attempt is abandoned, every segment part is deleted, and the file
|
|
62
|
+
falls back to the untouched single-stream path on that same endpoint. One
|
|
63
|
+
segmented attempt counts as one attempt against the endpoint-fallback ladder
|
|
64
|
+
(two direct rounds, then one system-proxy round).
|
|
65
|
+
|
|
45
66
|
Usage:
|
|
46
67
|
python stage_weights.py --dir MODEL_DIR \
|
|
47
68
|
--engine-script .../scripts/fetch_h3_image.py \
|
|
48
|
-
--endpoints hf,https://mirror.example
|
|
69
|
+
--endpoints hf,https://mirror.example \
|
|
70
|
+
[--seg-threshold BYTES]
|
|
49
71
|
"""
|
|
50
72
|
|
|
51
73
|
from __future__ import annotations
|
|
@@ -56,12 +78,21 @@ import sys
|
|
|
56
78
|
import urllib.error
|
|
57
79
|
import urllib.parse
|
|
58
80
|
import urllib.request
|
|
81
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
59
82
|
from pathlib import Path
|
|
60
83
|
|
|
61
84
|
TIMEOUT = 60 # seconds without data on any single read — same as the engine fetch
|
|
62
85
|
REDIRECT_CODES = (301, 302, 303, 307, 308)
|
|
63
86
|
HF = "https://huggingface.co"
|
|
64
87
|
|
|
88
|
+
# Files at or above this many bytes are fetched in parallel segments. The
|
|
89
|
+
# default is deliberately high: below it the extra connections and the stitch
|
|
90
|
+
# pass cost more than the wall-clock they save. Overridable with
|
|
91
|
+
# --seg-threshold (the L2 tests use a tiny value to exercise the path).
|
|
92
|
+
SEG_THRESHOLD = 64 * 1024 * 1024
|
|
93
|
+
SEG_COUNT = 4
|
|
94
|
+
STITCH_BLOCK = 1 << 22 # 4 MB — same block size the single-stream path streams with
|
|
95
|
+
|
|
65
96
|
|
|
66
97
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
67
98
|
"""Neutralise urllib's built-in redirect handling (it ignores 308); the
|
|
@@ -71,6 +102,43 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
|
71
102
|
return None
|
|
72
103
|
|
|
73
104
|
|
|
105
|
+
class _NoRangeError(RuntimeError):
|
|
106
|
+
"""A segmented request came back 200 instead of 206: this server does not
|
|
107
|
+
honour Range, so segmentation is off for the file and the single-stream
|
|
108
|
+
path takes over (never a reason to fail the whole endpoint)."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def seg_part_of(dest: Path, i: int) -> Path:
|
|
112
|
+
return dest.with_name(dest.name + f".part.{i}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def host_of(url: str) -> str:
|
|
116
|
+
return urllib.parse.urlsplit(url).hostname or url
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def clear_segment_parts(dest: Path) -> None:
|
|
120
|
+
"""Drop every segment part. Called after a successful stitch, after a
|
|
121
|
+
verification failure and on the 200 fallback — never after a plain network
|
|
122
|
+
failure, because those parts are what the next attempt resumes from."""
|
|
123
|
+
for i in range(SEG_COUNT):
|
|
124
|
+
seg_part_of(dest, i).unlink(missing_ok=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def segment_ranges(size: int, count: int = SEG_COUNT):
|
|
128
|
+
"""Contiguous [start, end) ranges covering [0, size), as even as possible.
|
|
129
|
+
Empty ranges are dropped; fewer than two ranges means there is nothing to
|
|
130
|
+
parallelise and the caller should stay on the single-stream path."""
|
|
131
|
+
base, extra = divmod(size, count)
|
|
132
|
+
ranges = []
|
|
133
|
+
start = 0
|
|
134
|
+
for i in range(count):
|
|
135
|
+
end = start + base + (1 if i < extra else 0)
|
|
136
|
+
if end > start:
|
|
137
|
+
ranges.append((start, end))
|
|
138
|
+
start = end
|
|
139
|
+
return ranges
|
|
140
|
+
|
|
141
|
+
|
|
74
142
|
def sha256_of(path: Path) -> str:
|
|
75
143
|
h = hashlib.sha256()
|
|
76
144
|
with path.open("rb") as fh:
|
|
@@ -145,8 +213,113 @@ def attempt(opener, url: str, dest: Path, part: Path, size: int, sha, name: str,
|
|
|
145
213
|
print(f"[stage {index}/{total}] placed {name}", flush=True)
|
|
146
214
|
|
|
147
215
|
|
|
216
|
+
def attempt_segment(opener, url: str, seg_part: Path, start: int, end: int,
|
|
217
|
+
name: str, i: int, index: int, total: int):
|
|
218
|
+
"""One endpoint, one byte range: stream into the segment's own .part
|
|
219
|
+
(resuming from its current size when the server honours Range). Returns
|
|
220
|
+
the final segment size. Raises _NoRangeError on a 200 (server ignores
|
|
221
|
+
Range → the caller falls back to single-stream) and RuntimeError on a
|
|
222
|
+
short/oversize transfer. A network failure leaves the partial segment part
|
|
223
|
+
on disk so the next attempt resumes it."""
|
|
224
|
+
host = urllib.parse.urlsplit(url).hostname or url
|
|
225
|
+
print(f"[stage {index}/{total}] seg {i}/{SEG_COUNT} {name} via {host}", flush=True)
|
|
226
|
+
seg_len = end - start
|
|
227
|
+
have = seg_part.stat().st_size if seg_part.exists() else 0
|
|
228
|
+
if have > seg_len:
|
|
229
|
+
# A segment part longer than its range can never be right (torn write
|
|
230
|
+
# or a stale part from a different layout): restart it from zero.
|
|
231
|
+
seg_part.unlink()
|
|
232
|
+
have = 0
|
|
233
|
+
if have == seg_len:
|
|
234
|
+
# Already complete from an earlier attempt — no request needed.
|
|
235
|
+
return have
|
|
236
|
+
# Closed range: the resume point is start+have, and the end stays pinned
|
|
237
|
+
# so a server that answers cannot overshoot the segment (an open-ended
|
|
238
|
+
# resume would pull the rest of the file into this segment).
|
|
239
|
+
headers = {"Range": f"bytes={start + have}-{end - 1}"}
|
|
240
|
+
with open_following(opener, url, headers) as resp:
|
|
241
|
+
if getattr(resp, "status", 200) != 206:
|
|
242
|
+
raise _NoRangeError(f"segment {i} of {name}: server answered {getattr(resp, 'status', 200)} to a Range request")
|
|
243
|
+
# 206 → append; the Range started exactly at `have`, so whatever is on
|
|
244
|
+
# disk stays and the missing tail streams in after it.
|
|
245
|
+
with seg_part.open("ab") as out:
|
|
246
|
+
while True:
|
|
247
|
+
block = resp.read(STITCH_BLOCK)
|
|
248
|
+
if not block:
|
|
249
|
+
break
|
|
250
|
+
out.write(block)
|
|
251
|
+
got = seg_part.stat().st_size if seg_part.exists() else 0
|
|
252
|
+
if got != seg_len:
|
|
253
|
+
if got > seg_len:
|
|
254
|
+
seg_part.unlink(missing_ok=True)
|
|
255
|
+
raise RuntimeError(f"segment {i} of {name}: expected {seg_len} bytes, got {got}")
|
|
256
|
+
return got
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def stitch_segments(dest: Path, part: Path, ranges) -> None:
|
|
260
|
+
"""Concatenate the segment parts into the single .part in order (blockwise,
|
|
261
|
+
so peak disk stays at ~2x the file), then drop the segment parts. The
|
|
262
|
+
caller's existing size/sha verification then judges the assembled part."""
|
|
263
|
+
with part.open("wb") as out:
|
|
264
|
+
for i, (_start, _end) in enumerate(ranges):
|
|
265
|
+
seg_part = seg_part_of(dest, i)
|
|
266
|
+
with seg_part.open("rb") as seg:
|
|
267
|
+
while True:
|
|
268
|
+
block = seg.read(STITCH_BLOCK)
|
|
269
|
+
if not block:
|
|
270
|
+
break
|
|
271
|
+
out.write(block)
|
|
272
|
+
clear_segment_parts(dest)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def attempt_segmented(opener, url: str, dest: Path, part: Path, size: int, name: str,
|
|
276
|
+
index: int, total: int) -> bool:
|
|
277
|
+
"""One endpoint, one whole-file segmented attempt: 4 ranges fetched
|
|
278
|
+
concurrently, stitched into the single .part. Returns True when the .part
|
|
279
|
+
is complete and ready for verification, False when the server does not
|
|
280
|
+
honour Range (parts cleaned, single-stream takes over). Other failures
|
|
281
|
+
raise with the segment parts kept for the next attempt's resume."""
|
|
282
|
+
host = urllib.parse.urlsplit(url).hostname or url
|
|
283
|
+
print(f"[stage {index}/{total}] fetching {name} via {host}", flush=True)
|
|
284
|
+
ranges = segment_ranges(size)
|
|
285
|
+
if len(ranges) < 2:
|
|
286
|
+
return False
|
|
287
|
+
# A previous single-stream attempt may have left a partial .part; the
|
|
288
|
+
# segment parts are the source of truth from here, so it would only be
|
|
289
|
+
# truncated by the stitch anyway.
|
|
290
|
+
part.unlink(missing_ok=True)
|
|
291
|
+
try:
|
|
292
|
+
with ThreadPoolExecutor(max_workers=SEG_COUNT) as pool:
|
|
293
|
+
futures = [
|
|
294
|
+
pool.submit(attempt_segment, opener, url, seg_part_of(dest, i), start, end,
|
|
295
|
+
name, i + 1, index, total)
|
|
296
|
+
for i, (start, end) in enumerate(ranges)
|
|
297
|
+
]
|
|
298
|
+
errors = []
|
|
299
|
+
for fut in as_completed(futures):
|
|
300
|
+
try:
|
|
301
|
+
fut.result()
|
|
302
|
+
except Exception as error: # noqa: BLE001 — collect, decide below
|
|
303
|
+
errors.append(error)
|
|
304
|
+
if errors:
|
|
305
|
+
# A 200 anywhere is a capability statement, not a transient
|
|
306
|
+
# failure: it must win over any other segment error so the
|
|
307
|
+
# single-stream fallback always triggers when Range is unsupported.
|
|
308
|
+
for error in errors:
|
|
309
|
+
if isinstance(error, _NoRangeError):
|
|
310
|
+
raise error
|
|
311
|
+
raise errors[0]
|
|
312
|
+
except _NoRangeError:
|
|
313
|
+
# Not this server's capability: clean slate, single-stream path from
|
|
314
|
+
# scratch — that path treats a 200 as a normal full-file download.
|
|
315
|
+
clear_segment_parts(dest)
|
|
316
|
+
return False
|
|
317
|
+
stitch_segments(dest, part, ranges)
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
|
|
148
321
|
def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
|
|
149
|
-
middle: str, index: int, total: int) -> bool:
|
|
322
|
+
middle: str, index: int, total: int, seg_threshold: int = SEG_THRESHOLD) -> bool:
|
|
150
323
|
"""Place one pinned file. Returns True on placed/skipped, False after every
|
|
151
324
|
endpoint failed in both the direct and the system-proxy round."""
|
|
152
325
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -163,6 +336,33 @@ def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
|
|
|
163
336
|
return True
|
|
164
337
|
part.unlink()
|
|
165
338
|
|
|
339
|
+
def attempt_endpoint(opener, url, first):
|
|
340
|
+
"""One endpoint for this file. Big files try the segmented route
|
|
341
|
+
first; a server that will not do Range (or a file too small to split)
|
|
342
|
+
goes through the untouched single-stream attempt()."""
|
|
343
|
+
if size < seg_threshold:
|
|
344
|
+
attempt(opener, url, dest, part, size, sha, name, index, total, first=first)
|
|
345
|
+
return
|
|
346
|
+
if not first:
|
|
347
|
+
print(f"[stage {index}/{total}] switched endpoint: {host_of(url)}", flush=True)
|
|
348
|
+
if attempt_segmented(opener, url, dest, part, size, name, index, total):
|
|
349
|
+
# Stitched: the same verification the single-stream path applies.
|
|
350
|
+
got = part.stat().st_size if part.exists() else 0
|
|
351
|
+
if got != size:
|
|
352
|
+
part.unlink(missing_ok=True)
|
|
353
|
+
clear_segment_parts(dest)
|
|
354
|
+
raise RuntimeError(f"expected {size} bytes, got {got}")
|
|
355
|
+
if sha is not None and sha256_of(part) != sha:
|
|
356
|
+
part.unlink(missing_ok=True)
|
|
357
|
+
clear_segment_parts(dest)
|
|
358
|
+
raise RuntimeError("sha256 mismatch")
|
|
359
|
+
part.rename(dest)
|
|
360
|
+
print(f"[stage {index}/{total}] placed {name}", flush=True)
|
|
361
|
+
return
|
|
362
|
+
# 200 fallback: the switch (if any) was already announced above, so
|
|
363
|
+
# single-stream runs with first=True and just re-announces fetching.
|
|
364
|
+
attempt(opener, url, dest, part, size, sha, name, index, total, first=True)
|
|
365
|
+
|
|
166
366
|
url_of = lambda endpoint: f"{endpoint.rstrip('/')}/{middle}/{rel}" # noqa: E731
|
|
167
367
|
last_error = None
|
|
168
368
|
attempts = 0
|
|
@@ -173,7 +373,7 @@ def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
|
|
|
173
373
|
for round_no in (1, 2):
|
|
174
374
|
for endpoint in endpoints:
|
|
175
375
|
try:
|
|
176
|
-
|
|
376
|
+
attempt_endpoint(direct, url_of(endpoint), first=attempts == 0)
|
|
177
377
|
return True
|
|
178
378
|
except Exception as error: # noqa: BLE001 — any failure means: next endpoint
|
|
179
379
|
last_error = error
|
|
@@ -184,7 +384,7 @@ def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
|
|
|
184
384
|
proxied = build_opener(direct=False)
|
|
185
385
|
for endpoint in endpoints:
|
|
186
386
|
try:
|
|
187
|
-
|
|
387
|
+
attempt_endpoint(proxied, url_of(endpoint), first=attempts == 0)
|
|
188
388
|
return True
|
|
189
389
|
except Exception as error: # noqa: BLE001
|
|
190
390
|
last_error = error
|
|
@@ -198,6 +398,8 @@ def main(argv=None) -> int:
|
|
|
198
398
|
ap.add_argument("--dir", type=Path, required=True, help="model root (the engine fetch's --dir)")
|
|
199
399
|
ap.add_argument("--engine-script", type=Path, required=True, help="absolute path to the engine's fetch_h3_image.py")
|
|
200
400
|
ap.add_argument("--endpoints", required=True, help="comma-separated priority list; 'hf' or https://host[/prefix] per entry")
|
|
401
|
+
ap.add_argument("--seg-threshold", type=int, default=SEG_THRESHOLD,
|
|
402
|
+
help=f"files at or above this many bytes download in {SEG_COUNT} concurrent Range segments (default {SEG_THRESHOLD})")
|
|
201
403
|
args = ap.parse_args(argv)
|
|
202
404
|
|
|
203
405
|
# The engine script's own directory goes on sys.path so `import
|
|
@@ -233,7 +435,8 @@ def main(argv=None) -> int:
|
|
|
233
435
|
total = len(items)
|
|
234
436
|
for index, (name, rel, dest, size, sha) in enumerate(items, 1):
|
|
235
437
|
ok = stage_file(name=name, rel=rel, dest=dest, size=size, sha=sha,
|
|
236
|
-
endpoints=endpoints, middle=middle, index=index, total=total
|
|
438
|
+
endpoints=endpoints, middle=middle, index=index, total=total,
|
|
439
|
+
seg_threshold=args.seg_threshold)
|
|
237
440
|
if not ok:
|
|
238
441
|
print(f"STAGE FAIL {rel}", file=sys.stderr, flush=True)
|
|
239
442
|
return 1
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "H3 Image (dsh-h3-image)",
|
|
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.11): 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 本地生图 (dsh-h3-image)",
|
|
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.11):只读环境体检 + 安装向导 + 本地产图(社区许可需你本人确认接受后才会出图)。"
|
|
5
5
|
}
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-h3-image",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
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",
|