@mcptoolshop/style-dataset-lab 3.2.0 → 3.3.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.
@@ -15,7 +15,10 @@ embedded python (has PIL): start the watchdog (E:/AI/training/_watchdog.ps1) bef
15
15
  from __future__ import annotations
16
16
 
17
17
  import argparse
18
+ import hashlib
18
19
  import json
20
+ import math
21
+ import os
19
22
  import time
20
23
  import urllib.request
21
24
  import uuid
@@ -31,6 +34,186 @@ QWEN_UNET = "qwen_image_fp8_e4m3fn.safetensors"
31
34
  QWEN_CLIP = "qwen_2.5_vl_7b_fp8_scaled.safetensors"
32
35
  QWEN_VAE = "qwen_image_vae.safetensors"
33
36
 
37
+ # ─── PIN_PER_STEP pinning contract ────────────────────────────────────────────
38
+ # Mirror of lib/run-manifest.js (the JS helper is the field-name authority). This
39
+ # receipt carries the IDENTICAL `pinning` block so a wave produced by this bridge
40
+ # generator is pinned the same way as one produced through the JS `sdlab generate`
41
+ # / `sdlab run generate` paths. Keep PINNING_VERSION + the field names in lockstep
42
+ # with lib/run-manifest.js.
43
+ PINNING_VERSION = "1.0.0"
44
+ MODELS_DIR = os.environ.get(
45
+ "SDLAB_MODELS_DIR", r"E:/AI-Models/ComfyUI_windows_portable/ComfyUI/models"
46
+ )
47
+ HASH_CACHE_FILE = os.environ.get("SDLAB_HASH_CACHE") or str(
48
+ Path(MODELS_DIR) / ".sdlab-hash-cache.json"
49
+ )
50
+ # kind → candidate subdirs under the models dir, in search order (mirror of JS).
51
+ _KIND_SUBDIRS = {
52
+ "unet": ["diffusion_models", "unet"],
53
+ "clip": ["text_encoders", "clip"],
54
+ "vae": ["vae"],
55
+ "checkpoint": ["checkpoints"],
56
+ "lora": ["loras"],
57
+ }
58
+
59
+
60
+ def _js_number(n) -> str:
61
+ """Format a number the way JS String(n) does, so the graph hash matches the JS
62
+ helper: integer-valued floats drop the decimal (1.0 -> "1"), others use the
63
+ shortest round-trip repr (3.1 -> "3.1")."""
64
+ if isinstance(n, bool):
65
+ return "true" if n else "false"
66
+ if isinstance(n, int):
67
+ return str(n)
68
+ if isinstance(n, float):
69
+ if not math.isfinite(n):
70
+ return "null"
71
+ return str(int(n)) if n.is_integer() else repr(n)
72
+ return "null"
73
+
74
+
75
+ def _stable_stringify(value) -> str:
76
+ """Deterministic serialization mirroring lib/run-manifest.js stableStringify:
77
+ object keys sorted, arrays in order, JS-style number/string formatting."""
78
+ if value is None:
79
+ return "null"
80
+ if isinstance(value, bool):
81
+ return "true" if value else "false"
82
+ if isinstance(value, (int, float)):
83
+ return _js_number(value)
84
+ if isinstance(value, str):
85
+ return json.dumps(value, ensure_ascii=False)
86
+ if isinstance(value, (list, tuple)):
87
+ return "[" + ",".join(_stable_stringify(v) for v in value) + "]"
88
+ if isinstance(value, dict):
89
+ # JS omits `undefined` keys; Python has no undefined, and None maps to JS
90
+ # `null` which stableStringify DOES serialize — so serialize every key.
91
+ parts = [
92
+ json.dumps(k, ensure_ascii=False) + ":" + _stable_stringify(value[k])
93
+ for k in sorted(value.keys())
94
+ ]
95
+ return "{" + ",".join(parts) + "}"
96
+ return "null"
97
+
98
+
99
+ def _normalize_graph_for_hash(graph: dict) -> dict:
100
+ """Blank per-item nondeterminism (seed, prompt text, filename_prefix) so the
101
+ whole wave shares one comfy_workflow_sha. Mirror of the JS normalizer."""
102
+ out = {}
103
+ for nid, node in (graph or {}).items():
104
+ inputs = dict(node.get("inputs", {}))
105
+ cls = node.get("class_type")
106
+ if cls in ("KSampler", "KSamplerAdvanced"):
107
+ if "seed" in inputs:
108
+ inputs["seed"] = 0
109
+ if "noise_seed" in inputs:
110
+ inputs["noise_seed"] = 0
111
+ elif cls == "CLIPTextEncode":
112
+ if "text" in inputs:
113
+ inputs["text"] = ""
114
+ elif cls == "SaveImage":
115
+ if "filename_prefix" in inputs:
116
+ inputs["filename_prefix"] = ""
117
+ out[nid] = {"class_type": cls, "inputs": inputs}
118
+ return out
119
+
120
+
121
+ def _comfy_workflow_sha(graph: dict):
122
+ if not graph:
123
+ return None
124
+ payload = _stable_stringify(_normalize_graph_for_hash(graph))
125
+ return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
126
+
127
+
128
+ def _resolve_model_path(name: str, kind: str):
129
+ if not name:
130
+ return None
131
+ subdirs = list(_KIND_SUBDIRS.get(kind, []))
132
+ for group in _KIND_SUBDIRS.values():
133
+ subdirs.extend(group)
134
+ subdirs.append(".")
135
+ seen = set()
136
+ for sub in subdirs:
137
+ cand = os.path.join(MODELS_DIR, sub, name)
138
+ if cand in seen:
139
+ continue
140
+ seen.add(cand)
141
+ if os.path.exists(cand):
142
+ return cand
143
+ return None
144
+
145
+
146
+ def _sha256_file(path: str) -> str:
147
+ h = hashlib.sha256()
148
+ with open(path, "rb") as f:
149
+ for chunk in iter(lambda: f.read(1 << 20), b""):
150
+ h.update(chunk)
151
+ return "sha256:" + h.hexdigest()
152
+
153
+
154
+ def _load_hash_cache() -> dict:
155
+ try:
156
+ with open(HASH_CACHE_FILE, "r", encoding="utf-8") as f:
157
+ return json.load(f)
158
+ except Exception:
159
+ return {}
160
+
161
+
162
+ def _save_hash_cache(cache: dict) -> None:
163
+ try:
164
+ with open(HASH_CACHE_FILE, "w", encoding="utf-8") as f:
165
+ json.dump(cache, f, indent=2)
166
+ except Exception:
167
+ pass
168
+
169
+
170
+ def _hash_model_file(name: str, kind: str, do_hash: bool, cache: dict) -> dict:
171
+ """Best-effort {name, size_bytes, sha256} for a model/LoRA file (mirror of the
172
+ JS hashModelFile). size_bytes is always filled when resolvable; sha256 is
173
+ gated behind do_hash and cached by (name, size, mtime)."""
174
+ if not name:
175
+ return {"name": name, "size_bytes": None, "sha256": None, "hash_note": "no filename"}
176
+ resolved = _resolve_model_path(name, kind)
177
+ if not resolved:
178
+ return {"name": name, "size_bytes": None, "sha256": None, "hash_note": f"unresolved under {MODELS_DIR}"}
179
+ try:
180
+ st = os.stat(resolved)
181
+ size_bytes = st.st_size
182
+ mtime_ms = round(st.st_mtime * 1000)
183
+ except OSError:
184
+ return {"name": name, "size_bytes": None, "sha256": None, "hash_note": "stat failed"}
185
+ if not do_hash:
186
+ return {"name": name, "size_bytes": size_bytes, "sha256": None, "hash_note": "hash skipped (pass --hash-models)"}
187
+ key = f"{name}:{size_bytes}:{mtime_ms}"
188
+ if key in cache:
189
+ return {"name": name, "size_bytes": size_bytes, "sha256": cache[key]}
190
+ try:
191
+ sha = _sha256_file(resolved)
192
+ except OSError:
193
+ return {"name": name, "size_bytes": size_bytes, "sha256": None, "hash_note": "hash failed (read error)"}
194
+ cache[key] = sha
195
+ return {"name": name, "size_bytes": size_bytes, "sha256": sha}
196
+
197
+
198
+ def _build_pinning(graph, models, loras, seed_policy, do_hash, cache) -> dict:
199
+ """Assemble the pinning block — identical shape to lib/run-manifest.js buildPinning."""
200
+ model_hashes = {}
201
+ for role, kind in (("unet", "unet"), ("clip", "clip"), ("vae", "vae"), ("checkpoint", "checkpoint")):
202
+ if models.get(role):
203
+ model_hashes[role] = _hash_model_file(models[role], kind, do_hash, cache)
204
+ lora_hashes = []
205
+ for lora in loras or []:
206
+ entry = _hash_model_file(lora.get("name"), "lora", do_hash, cache)
207
+ entry["weight"] = lora.get("weight", 1.0)
208
+ lora_hashes.append(entry)
209
+ return {
210
+ "pinning_version": PINNING_VERSION,
211
+ "comfy_workflow_sha": _comfy_workflow_sha(graph),
212
+ "seed_policy": seed_policy,
213
+ "models": model_hashes,
214
+ "loras": lora_hashes,
215
+ }
216
+
34
217
 
35
218
  def qwen_graph(pos: str, neg: str, seed: int, w: int, h: int, steps: int, cfg: float, prefix: str,
36
219
  loras: list | None = None) -> dict:
@@ -89,6 +272,8 @@ def main() -> None:
89
272
  ap.add_argument("--wave", required=True, type=Path)
90
273
  ap.add_argument("--out", required=True, type=Path)
91
274
  ap.add_argument("--variations", type=int, default=None, help="override per-subject variation count")
275
+ ap.add_argument("--hash-models", action="store_true",
276
+ help="content-hash the unet/clip/vae/LoRA files into the pinning block (best-effort, cached)")
92
277
  args = ap.parse_args()
93
278
 
94
279
  wave = json.loads(args.wave.read_text(encoding="utf-8"))
@@ -105,6 +290,22 @@ def main() -> None:
105
290
  "vae": QWEN_VAE, "sampler": "euler", "scheduler": "simple", "shift": 3.1,
106
291
  "steps": steps, "cfg": cfg, "size": [w, h], "style_prefix": prefix, "negative": neg,
107
292
  "loras": loras, "items": []}
293
+
294
+ # PIN_PER_STEP: pin the exact graph + model/LoRA content identity + seed policy.
295
+ # The wave shares ONE comfy_workflow_sha (per-item seed + prompt are normalized
296
+ # out and recorded per item in items[]). seed_policy is "explicit-per-item"
297
+ # because each item's seed is written verbatim below. Mirror of the JS contract.
298
+ hash_cache = _load_hash_cache()
299
+ pin_graph = qwen_graph(prefix or "x", neg, base_seed, w, h, steps, cfg, "sdl", loras=loras)
300
+ receipt["pinning"] = _build_pinning(
301
+ pin_graph,
302
+ {"unet": QWEN_UNET, "clip": QWEN_CLIP, "vae": QWEN_VAE},
303
+ loras,
304
+ "explicit-per-item",
305
+ args.hash_models,
306
+ hash_cache,
307
+ )
308
+
108
309
  seed = base_seed
109
310
  for subj in wave["subjects"]:
110
311
  nvar = args.variations if args.variations is not None else subj.get("variations", 1)
@@ -116,6 +317,8 @@ def main() -> None:
116
317
  receipt["items"].append({"id": subj["id"], "variation": v, "seed": seed, "file": dst.name, "prompt": pos})
117
318
  seed += 1
118
319
 
320
+ if args.hash_models:
321
+ _save_hash_cache(hash_cache)
119
322
  (args.out / "generation.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8")
120
323
  print(f"\n{len(receipt['items'])} images -> {args.out} (+ generation.json provenance)")
121
324
 
@@ -24,6 +24,7 @@ export async function run(argv = process.argv.slice(2)) {
24
24
  brief: { type: 'string' },
25
25
  seed: { type: 'string' },
26
26
  'dry-run': { type: 'boolean' },
27
+ 'hash-models': { type: 'boolean' },
27
28
  json: { type: 'boolean' },
28
29
  },
29
30
  deprecated: { game: 'project' },
@@ -36,6 +37,7 @@ export async function run(argv = process.argv.slice(2)) {
36
37
  console.log(' --brief <id> Compiled brief ID (required)');
37
38
  console.log(' --seed <n> Override base seed');
38
39
  console.log(' --dry-run Prepare run dir without submitting to ComfyUI');
40
+ console.log(' --hash-models Content-hash the model/LoRA files into the run manifest (best-effort, cached)');
39
41
  console.log(' --json Output manifest as JSON');
40
42
  return;
41
43
  }
@@ -59,6 +61,7 @@ export async function run(argv = process.argv.slice(2)) {
59
61
  brief,
60
62
  baseSeed: flags.seed ? parseNumberFlag('seed', flags.seed, { int: true, min: 0 }) : undefined,
61
63
  dryRun: flags['dry-run'] || false,
64
+ hashModels: flags['hash-models'] || false,
62
65
  });
63
66
 
64
67
  // Save summary