dsh-h3-image 0.2.9 → 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 CHANGED
@@ -34,10 +34,10 @@
34
34
  | 安装期间磁盘 | 峰值约 60 GB |
35
35
  | 装完后磁盘 | 约 53 GB(源分片转换完会自动删掉) |
36
36
  | 单张图内存(1 MP) | 约 27.5 GB |
37
- | 每个权重分片(下载 + 转换) | 约 4–5 分钟,看你的网速 |
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) | ~4–5 min, depends on your connection |
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
 
@@ -12,12 +12,17 @@ returned without any network access, so the following engine run is instant
12
12
  for the main weights while still converting shard by shard as usual.
13
13
 
14
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.
15
+ disabled — a proxy download would silently spend the user's proxy data). The
16
+ direct round runs twice: mirrors are often flaky in bursts, and a second
17
+ direct round costs nothing next to silently spending proxy data. Only after
18
+ both direct rounds fail does one final round run through the system proxy,
19
+ announced by its own line before it starts. Redirects (301/302/303/307/308)
20
+ are followed by hand because urllib does not follow 308, which is exactly
21
+ what a mirror answers for every file; the Range header is kept across hops
22
+ so a resumed download stays resumed through a 308. When a redirect chain
23
+ lands on a different host than the endpoint asked for (a mirror bouncing
24
+ users back to the origin), a note line says so — a slow file behind such a
25
+ bounce is the mirror's doing, and the log should not hide it.
21
26
 
22
27
  File-list truth: imported from the engine script (--engine-script), so a pin
23
28
  bump needs no change here.
@@ -27,6 +32,9 @@ Progress contract (parsed by lib/jobs.js, all flushed):
27
32
  [stage n/N] placed <name>
28
33
  [stage n/N] skipped <name> (already present)
29
34
  [stage n/N] switched endpoint: <host>
35
+ [stage n/N] note: <name> redirected to <host>
36
+ [stage n/N] seg <i>/4 <name> via <host> (segmented mode only)
37
+ stage: <name>: direct round failed (<err>); retrying direct once
30
38
  stage: retrying via system proxy (may count against proxy data)
31
39
  STAGE OK (terminal, stdout)
32
40
  STAGE FAIL <rel> (terminal, stderr, exit 1)
@@ -35,10 +43,31 @@ Resumability: a `.part` survives a failed attempt and resumes with a Range
35
43
  request; a server that ignores Range restarts the file cleanly (200 →
36
44
  truncate). A part that fails sha256 is deleted, never renamed into place.
37
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
+
38
66
  Usage:
39
67
  python stage_weights.py --dir MODEL_DIR \
40
68
  --engine-script .../scripts/fetch_h3_image.py \
41
- --endpoints hf,https://mirror.example
69
+ --endpoints hf,https://mirror.example \
70
+ [--seg-threshold BYTES]
42
71
  """
43
72
 
44
73
  from __future__ import annotations
@@ -49,12 +78,21 @@ import sys
49
78
  import urllib.error
50
79
  import urllib.parse
51
80
  import urllib.request
81
+ from concurrent.futures import ThreadPoolExecutor, as_completed
52
82
  from pathlib import Path
53
83
 
54
84
  TIMEOUT = 60 # seconds without data on any single read — same as the engine fetch
55
85
  REDIRECT_CODES = (301, 302, 303, 307, 308)
56
86
  HF = "https://huggingface.co"
57
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
+
58
96
 
59
97
  class _NoRedirect(urllib.request.HTTPRedirectHandler):
60
98
  """Neutralise urllib's built-in redirect handling (it ignores 308); the
@@ -64,6 +102,43 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
64
102
  return None
65
103
 
66
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
+
67
142
  def sha256_of(path: Path) -> str:
68
143
  h = hashlib.sha256()
69
144
  with path.open("rb") as fh:
@@ -113,6 +188,12 @@ def attempt(opener, url: str, dest: Path, part: Path, size: int, sha, name: str,
113
188
  have = 0
114
189
  headers = {"Range": f"bytes={have}-"} if have else {}
115
190
  with open_following(opener, url, headers) as resp:
191
+ final_host = urllib.parse.urlsplit(resp.geturl()).hostname or host
192
+ if final_host != host:
193
+ # A mirror that bounces to another host (typically the origin) is
194
+ # still "via <mirror>" in the log above; say where the bytes
195
+ # actually come from so a slow/stalled transfer is attributed right.
196
+ print(f"[stage {index}/{total}] note: {name} redirected to {final_host}", flush=True)
116
197
  resume = bool(have) and getattr(resp, "status", 200) == 206
117
198
  with part.open("ab" if resume else "wb") as out:
118
199
  while True:
@@ -132,8 +213,113 @@ def attempt(opener, url: str, dest: Path, part: Path, size: int, sha, name: str,
132
213
  print(f"[stage {index}/{total}] placed {name}", flush=True)
133
214
 
134
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
+
135
321
  def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
136
- middle: str, index: int, total: int) -> bool:
322
+ middle: str, index: int, total: int, seg_threshold: int = SEG_THRESHOLD) -> bool:
137
323
  """Place one pinned file. Returns True on placed/skipped, False after every
138
324
  endpoint failed in both the direct and the system-proxy round."""
139
325
  dest.parent.mkdir(parents=True, exist_ok=True)
@@ -150,22 +336,55 @@ def stage_file(*, name: str, rel: str, dest: Path, size: int, sha, endpoints,
150
336
  return True
151
337
  part.unlink()
152
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
+
153
366
  url_of = lambda endpoint: f"{endpoint.rstrip('/')}/{middle}/{rel}" # noqa: E731
154
367
  last_error = None
155
368
  attempts = 0
156
369
  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
370
+ # Two direct rounds before touching the proxy: mirror flakiness is bursty
371
+ # (a file that times out once often succeeds seconds later), and a second
372
+ # direct round is free while proxy bytes are not.
373
+ for round_no in (1, 2):
374
+ for endpoint in endpoints:
375
+ try:
376
+ attempt_endpoint(direct, url_of(endpoint), first=attempts == 0)
377
+ return True
378
+ except Exception as error: # noqa: BLE001 — any failure means: next endpoint
379
+ last_error = error
380
+ attempts += 1
381
+ if round_no == 1:
382
+ print(f"stage: {name}: direct round failed ({last_error}); retrying direct once", flush=True)
164
383
  print("stage: retrying via system proxy (may count against proxy data)", flush=True)
165
384
  proxied = build_opener(direct=False)
166
385
  for endpoint in endpoints:
167
386
  try:
168
- attempt(proxied, url_of(endpoint), dest, part, size, sha, name, index, total, first=attempts == 0)
387
+ attempt_endpoint(proxied, url_of(endpoint), first=attempts == 0)
169
388
  return True
170
389
  except Exception as error: # noqa: BLE001
171
390
  last_error = error
@@ -179,6 +398,8 @@ def main(argv=None) -> int:
179
398
  ap.add_argument("--dir", type=Path, required=True, help="model root (the engine fetch's --dir)")
180
399
  ap.add_argument("--engine-script", type=Path, required=True, help="absolute path to the engine's fetch_h3_image.py")
181
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})")
182
403
  args = ap.parse_args(argv)
183
404
 
184
405
  # The engine script's own directory goes on sys.path so `import
@@ -214,7 +435,8 @@ def main(argv=None) -> int:
214
435
  total = len(items)
215
436
  for index, (name, rel, dest, size, sha) in enumerate(items, 1):
216
437
  ok = stage_file(name=name, rel=rel, dest=dest, size=size, sha=sha,
217
- endpoints=endpoints, middle=middle, index=index, total=total)
438
+ endpoints=endpoints, middle=middle, index=index, total=total,
439
+ seg_threshold=args.seg_threshold)
218
440
  if not ok:
219
441
  print(f"STAGE FAIL {rel}", file=sys.stderr, flush=True)
220
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.9): read-only environment check, setup wizard, and local generation (the community license must be explicitly accepted by you before the first 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.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.9):只读环境体检 + 安装向导 + 本地产图(社区许可需你本人确认接受后才会出图)。"
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.9",
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",