@mcptoolshop/style-dataset-lab 3.3.0 → 3.5.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 (119) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +117 -55
  3. package/README.fr.md +125 -65
  4. package/README.hi.md +117 -68
  5. package/README.it.md +119 -58
  6. package/README.ja.md +124 -74
  7. package/README.md +73 -7
  8. package/README.pt-BR.md +128 -63
  9. package/README.zh.md +116 -65
  10. package/bin/sdlab.js +100 -4
  11. package/lib/adapters/ai-toolkit.js +224 -35
  12. package/lib/adapters/comfyui-runner.js +79 -26
  13. package/lib/adapters/comfyui-workflows.js +94 -11
  14. package/lib/adapters/diffusers-lora.js +37 -5
  15. package/lib/adapters/generic-image-caption.js +43 -5
  16. package/lib/args.js +16 -1
  17. package/lib/asset-ingest.js +575 -0
  18. package/lib/asset-masks.js +323 -0
  19. package/lib/asset-source.js +1176 -0
  20. package/lib/batch-modes.js +29 -8
  21. package/lib/batch-runs.js +29 -1
  22. package/lib/batch-sheet-render.js +13 -3
  23. package/lib/brief-compiler.js +19 -0
  24. package/lib/candidate-sheet.js +327 -0
  25. package/lib/canon-author.js +350 -0
  26. package/lib/canon-build/build.js +146 -93
  27. package/lib/canon-review-gate.js +205 -0
  28. package/lib/captions.js +57 -0
  29. package/lib/card.js +74 -2
  30. package/lib/colab-launcher-guards.js +285 -0
  31. package/lib/comfyui.js +116 -18
  32. package/lib/config.js +116 -12
  33. package/lib/critique-engine.js +36 -2
  34. package/lib/eligibility.js +121 -9
  35. package/lib/eval-pack.js +14 -14
  36. package/lib/eval-runs.js +33 -0
  37. package/lib/export.js +122 -34
  38. package/lib/freeze-gate.js +16 -3
  39. package/lib/freeze-stamp.js +28 -3
  40. package/lib/generated-provenance.js +194 -6
  41. package/lib/implementation-packs.js +95 -17
  42. package/lib/ingest.js +396 -0
  43. package/lib/judgment-provenance.js +220 -0
  44. package/lib/lane1-render.js +668 -0
  45. package/lib/measure.js +422 -0
  46. package/lib/memory-store.js +663 -0
  47. package/lib/model-licences.js +305 -0
  48. package/lib/npy-meta.js +130 -0
  49. package/lib/paths.js +196 -7
  50. package/lib/png-meta.js +512 -0
  51. package/lib/records.js +54 -8
  52. package/lib/reingest-selected.js +113 -17
  53. package/lib/reingest.js +27 -3
  54. package/lib/run-manifest.js +159 -25
  55. package/lib/run-summary.js +12 -6
  56. package/lib/runtime-runs.js +45 -4
  57. package/lib/snapshot.js +114 -15
  58. package/lib/split.js +364 -48
  59. package/lib/train-runs.js +214 -0
  60. package/lib/training-manifests.js +114 -21
  61. package/lib/training-packages.js +147 -15
  62. package/lib/verifier.js +3 -0
  63. package/lib/verify-chain.js +367 -0
  64. package/lib/workflow-profiles.js +24 -3
  65. package/package.json +4 -3
  66. package/runtime/comfyui/txt2img-qwen.json +38 -0
  67. package/runtime/comfyui/txt2img-standard.json +21 -0
  68. package/scripts/_resume.js +62 -0
  69. package/scripts/_shared-args.js +44 -0
  70. package/scripts/asset-ingest.js +160 -0
  71. package/scripts/brief-compile.js +2 -1
  72. package/scripts/brief-show.js +2 -1
  73. package/scripts/canon-bind.js +2 -2
  74. package/scripts/canon-build.js +27 -20
  75. package/scripts/canon-draft.js +82 -0
  76. package/scripts/canon-drift.js +16 -7
  77. package/scripts/canon-freeze-status.js +14 -6
  78. package/scripts/canon-freeze.js +31 -11
  79. package/scripts/canon-unfreeze.js +17 -9
  80. package/scripts/card.js +2 -2
  81. package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
  82. package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
  83. package/scripts/colab/colab_window.sh +587 -0
  84. package/scripts/colab/lane1_render.py +970 -0
  85. package/scripts/colab/launch_guards.py +564 -0
  86. package/scripts/colab/launch_lane1_stub.sh +309 -0
  87. package/scripts/colab/launch_lane3_stub.sh +366 -0
  88. package/scripts/colab/remote_runner.py +59 -0
  89. package/scripts/compare.js +2 -2
  90. package/scripts/critique.js +16 -1
  91. package/scripts/curate.js +2 -2
  92. package/scripts/doctor.js +182 -55
  93. package/scripts/eligibility.js +42 -2
  94. package/scripts/eval-pack.js +2 -2
  95. package/scripts/eval-run.js +2 -2
  96. package/scripts/export.js +2 -2
  97. package/scripts/generate-controlnet.js +45 -25
  98. package/scripts/generate-identity.js +53 -23
  99. package/scripts/generate-ipadapter.js +44 -23
  100. package/scripts/generate.js +86 -17
  101. package/scripts/implementation-pack.js +2 -2
  102. package/scripts/ingest.js +145 -0
  103. package/scripts/init.js +73 -13
  104. package/scripts/measure.js +170 -0
  105. package/scripts/measure_image.py +458 -0
  106. package/scripts/migrate-records.js +142 -0
  107. package/scripts/painterly-test.js +4 -3
  108. package/scripts/painterly.js +7 -3
  109. package/scripts/qwen_generate.py +138 -20
  110. package/scripts/reingest.js +2 -2
  111. package/scripts/sheet.js +142 -0
  112. package/scripts/snapshot.js +2 -2
  113. package/scripts/split.js +2 -2
  114. package/scripts/training-manifest.js +2 -2
  115. package/scripts/training-package.js +2 -2
  116. package/scripts/training-profile.js +2 -2
  117. package/scripts/verify.js +67 -0
  118. package/scripts/workflow-list.js +2 -1
  119. package/scripts/workflow-show.js +2 -1
@@ -0,0 +1,970 @@
1
+ #!/usr/bin/env python3
2
+ """Lane 1 (render) VM bootstrap and durable ComfyUI queue driver (DESIGN-v3 §3.1, §3.5, §4.4, §7, G1-G3, G8).
3
+
4
+ Drives headless ComfyUI on loopback (127.0.0.1) via the `comfy_headless` library
5
+ (never launching the Gradio UI). Enforces CPython 3.15.1 in a uv venv, stages
6
+ weights by sha256 + cleared SPDX licence from a manifest, writes `window.json`,
7
+ executes `jobs.jsonl` -> `renders.jsonl` with idempotent resume after kill,
8
+ periodic copy-back, and PNG `tEXt` provenance stamping + immediate read-back
9
+ verification via `comfy_headless.read_png_text_chunks`.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import shutil
19
+ import socket
20
+ import struct
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ import urllib.parse
25
+ import urllib.request
26
+ import zlib
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
32
+ PY_VERSION_EXPECTED = "3.15.1"
33
+ UV_PIN = "uv==0.13.5"
34
+ LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1", "[::1]"}
35
+ CLEARED_LICENCES = {"Apache-2.0", "MIT", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", "CC-BY-4.0"}
36
+
37
+
38
+ @dataclass
39
+ class _WorkflowMetadataFallback:
40
+ has_prompt: bool
41
+ prompt: dict[str, Any] | None = None
42
+
43
+
44
+ def _read_png_text_chunks_fallback(png_source: Path | bytes) -> dict[str, str]:
45
+ data = png_source.read_bytes() if isinstance(png_source, Path) else bytes(png_source)
46
+ if not data.startswith(PNG_SIGNATURE):
47
+ raise ValueError("PNG_INVALID: bad PNG signature")
48
+ chunks: dict[str, str] = {}
49
+ offset = len(PNG_SIGNATURE)
50
+ size = len(data)
51
+ while offset + 8 <= size:
52
+ (length,) = struct.unpack(">I", data[offset : offset + 4])
53
+ ctype = data[offset + 4 : offset + 8]
54
+ chunk_end = offset + 8 + length + 4
55
+ if chunk_end > size:
56
+ break
57
+ payload = data[offset + 8 : offset + 8 + length]
58
+ offset = chunk_end
59
+ if ctype == b"tEXt":
60
+ kw, sep, val = payload.partition(b"\x00")
61
+ if sep:
62
+ chunks[kw.decode("latin-1", errors="replace")] = val.decode("latin-1", errors="replace")
63
+ elif ctype == b"IEND":
64
+ break
65
+ return chunks
66
+
67
+
68
+ def _read_workflow_metadata_fallback(png_source: Path | bytes) -> _WorkflowMetadataFallback:
69
+ chunks = _read_png_text_chunks_fallback(png_source)
70
+ raw_prompt = chunks.get("prompt")
71
+ if not raw_prompt:
72
+ return _WorkflowMetadataFallback(has_prompt=False, prompt=None)
73
+ try:
74
+ parsed = json.loads(raw_prompt)
75
+ return _WorkflowMetadataFallback(has_prompt=isinstance(parsed, dict) and bool(parsed), prompt=parsed)
76
+ except Exception:
77
+ return _WorkflowMetadataFallback(has_prompt=False, prompt=None)
78
+
79
+
80
+ try:
81
+ from comfy_headless import (
82
+ ComfyClient,
83
+ __version__ as COMFY_HEADLESS_VERSION,
84
+ read_png_text_chunks,
85
+ read_workflow_metadata,
86
+ )
87
+ except ImportError:
88
+ ComfyClient = None # type: ignore[assignment]
89
+ COMFY_HEADLESS_VERSION = "0.1.0-fallback"
90
+ read_png_text_chunks = _read_png_text_chunks_fallback
91
+ read_workflow_metadata = _read_workflow_metadata_fallback
92
+
93
+ STAMP_KEYS = {
94
+ "owner": "sdlab:owner",
95
+ "licence": "sdlab:licence",
96
+ "canon_sha": "sdlab:canon_sha",
97
+ "entry_id": "sdlab:entry_id",
98
+ "entry_sha256": "sdlab:entry_sha256",
99
+ "run_id": "sdlab:run_id",
100
+ "workflow_sha": "sdlab:workflow_sha",
101
+ "window_id": "sdlab:window_id",
102
+ }
103
+ REQUIRED_STAMP_FIELDS = ("owner", "licence", "canon_sha", "entry_id", "entry_sha256", "run_id")
104
+
105
+
106
+ def sha256_bytes(data: bytes) -> str:
107
+ return "sha256:" + hashlib.sha256(data).hexdigest()
108
+
109
+
110
+ def sha256_file(path: Path) -> str:
111
+ h = hashlib.sha256()
112
+ with path.open("rb") as f:
113
+ for chunk in iter(lambda: f.read(1 << 20), b""):
114
+ h.update(chunk)
115
+ return "sha256:" + h.hexdigest()
116
+
117
+
118
+ def normalize_sha256(val: str | None) -> str | None:
119
+ if not val or not isinstance(val, str):
120
+ return None
121
+ s = val.strip().lower()
122
+ if s.startswith("sha256:"):
123
+ s = s[7:]
124
+ if len(s) != 64 or any(c not in "0123456789abcdef" for c in s):
125
+ return None
126
+ return f"sha256:{s}"
127
+
128
+
129
+ def assert_loopback_url(comfy_url: str) -> tuple[str, int]:
130
+ parsed = urllib.parse.urlparse(comfy_url)
131
+ host = (parsed.hostname or "").lower()
132
+ port = parsed.port or 8188
133
+ if host not in LOOPBACK_HOSTS:
134
+ raise ValueError(f"LANE1_NON_LOOPBACK_BIND: ComfyUI URL {comfy_url!r} is not loopback-only (127.0.0.1)")
135
+ return host, port
136
+
137
+
138
+ def parse_proc_net_tcp_records(text: str, ipv6: bool = False) -> list[dict[str, str]]:
139
+ """Parse Linux /proc/net/tcp or /proc/net/tcp6 and return TCP_LISTEN (st=0A) records with inode."""
140
+ out: list[dict[str, str]] = []
141
+ seen: set[tuple[str, str]] = set()
142
+ for raw_line in text.splitlines():
143
+ parts = raw_line.strip().split()
144
+ if len(parts) < 4 or ":" not in parts[1]:
145
+ continue
146
+ state = parts[3].upper()
147
+ if state != "0A":
148
+ continue
149
+ addr_hex, port_hex = parts[1].split(":", 1)
150
+ try:
151
+ port = int(port_hex, 16)
152
+ if ipv6 and len(addr_hex) == 32:
153
+ words = [int(addr_hex[i : i + 8], 16) for i in range(0, 32, 8)]
154
+ raw_ip = struct.pack("<IIII", *words)
155
+ host = f"[{socket.inet_ntop(socket.AF_INET6, raw_ip)}]"
156
+ elif not ipv6 and len(addr_hex) == 8:
157
+ raw_ip = struct.pack("<I", int(addr_hex, 16))
158
+ host = socket.inet_ntop(socket.AF_INET, raw_ip)
159
+ else:
160
+ continue
161
+ except (ValueError, OSError):
162
+ continue
163
+ inode = parts[9] if len(parts) >= 10 else ""
164
+ address = f"{host}:{port}"
165
+ key = (address, inode)
166
+ if key not in seen:
167
+ seen.add(key)
168
+ out.append({"address": address, "inode": inode})
169
+ return out
170
+
171
+
172
+ def parse_proc_net_tcp(text: str, ipv6: bool = False) -> list[str]:
173
+ """Parse Linux /proc/net/tcp or /proc/net/tcp6 and return TCP_LISTEN (st=0A) sockets."""
174
+ out: list[str] = []
175
+ for rec in parse_proc_net_tcp_records(text, ipv6=ipv6):
176
+ addr = rec["address"]
177
+ if addr not in out:
178
+ out.append(addr)
179
+ return out
180
+
181
+
182
+ def parse_ss_tln(text: str) -> list[str]:
183
+ """Parse `ss -tln` output and return LISTEN local address:port strings."""
184
+ out: list[str] = []
185
+ for raw_line in text.splitlines():
186
+ parts = raw_line.strip().split()
187
+ if len(parts) < 4 or parts[0].upper() != "LISTEN":
188
+ continue
189
+ local_addr = parts[3].strip()
190
+ if ":" in local_addr and local_addr not in out:
191
+ out.append(local_addr)
192
+ return out
193
+
194
+
195
+ def probe_pid_socket_inodes(
196
+ comfy_pid: int | None = None,
197
+ *,
198
+ proc_root: Path | None = None,
199
+ allow_host_proc: bool = True,
200
+ ) -> tuple[set[str], str]:
201
+ """Read `socket:[inode]` entries from `/proc/<pid>/fd` (symlink or fixture file)."""
202
+ if comfy_pid is None:
203
+ return set(), "port"
204
+
205
+ fd_dir: Path | None = None
206
+ if proc_root is not None:
207
+ fd_dir = proc_root / str(comfy_pid) / "fd"
208
+ elif allow_host_proc:
209
+ fd_dir = Path(f"/proc/{comfy_pid}/fd")
210
+
211
+ if fd_dir is None or not fd_dir.exists() or not fd_dir.is_dir():
212
+ raise ValueError(
213
+ f"LANE1_COMFY_PID_FD_UNREADABLE: fd directory for comfy_pid={comfy_pid} is absent or not a directory ({fd_dir})"
214
+ )
215
+
216
+ try:
217
+ entries = sorted(fd_dir.iterdir(), key=lambda p: p.name)
218
+ except OSError as exc:
219
+ raise ValueError(
220
+ f"LANE1_COMFY_PID_FD_UNREADABLE: failed to read fd directory for comfy_pid={comfy_pid} ({fd_dir}): {exc}"
221
+ ) from exc
222
+
223
+ inodes: set[str] = set()
224
+ for entry in entries:
225
+ target = ""
226
+ try:
227
+ target = os.readlink(entry)
228
+ except OSError:
229
+ if proc_root is not None and entry.is_file():
230
+ target = entry.read_text(encoding="utf-8", errors="replace").strip()
231
+ if target.startswith("socket:[") and target.endswith("]"):
232
+ inode = target[len("socket:[") : -1].strip()
233
+ if inode.isdigit():
234
+ inodes.add(inode)
235
+ return inodes, "inode"
236
+
237
+
238
+ def inspect_comfy_listeners(
239
+ sockets: list[str] | list[dict[str, str]],
240
+ comfy_port: int = 8188,
241
+ *,
242
+ comfy_pid: int | None = None,
243
+ proc_root: Path | None = None,
244
+ allow_host_proc: bool = True,
245
+ ) -> tuple[list[str], list[str], str]:
246
+ """Partition listening sockets into ComfyUI sockets and other_listeners, verifying ComfyUI is loopback-only."""
247
+ parsed_records: list[tuple[str, str, int, str]] = []
248
+ for item in sockets:
249
+ if isinstance(item, dict):
250
+ raw_addr = str(item.get("address", "")).strip()
251
+ inode = str(item.get("inode", "")).strip()
252
+ else:
253
+ raw_addr = str(item).strip()
254
+ inode = ""
255
+ if ":" not in raw_addr:
256
+ continue
257
+ host_part, port_str = raw_addr.rsplit(":", 1)
258
+ try:
259
+ port_num = int(port_str)
260
+ except ValueError:
261
+ continue
262
+ parsed_records.append((raw_addr, host_part.strip(), port_num, inode))
263
+
264
+ if not parsed_records:
265
+ raise ValueError("LANE1_COMFY_SOCKET_NOT_FOUND: listening_sockets list has no valid address:port entries")
266
+
267
+ inode_set, socket_match = probe_pid_socket_inodes(
268
+ comfy_pid,
269
+ proc_root=proc_root,
270
+ allow_host_proc=allow_host_proc,
271
+ )
272
+
273
+ comfy_sockets: list[str] = []
274
+ other_listeners: list[str] = []
275
+ for addr, _host_part, port_num, inode in parsed_records:
276
+ is_comfy = (bool(inode) and inode in inode_set) if socket_match == "inode" else (port_num == comfy_port)
277
+ target_list = comfy_sockets if is_comfy else other_listeners
278
+ if addr not in target_list:
279
+ target_list.append(addr)
280
+
281
+ if not comfy_sockets:
282
+ raise ValueError(
283
+ f"LANE1_COMFY_SOCKET_NOT_FOUND: no listening socket found for ComfyUI "
284
+ f"(pid={comfy_pid}, port={comfy_port}) in {[r[0] for r in parsed_records]!r}"
285
+ )
286
+
287
+ for addr in comfy_sockets:
288
+ host_part, _ = addr.rsplit(":", 1)
289
+ host_clean = host_part.strip().lower()
290
+ if host_clean.startswith("[") and host_clean.endswith("]"):
291
+ host_clean = host_clean[1:-1]
292
+ if host_clean not in ("127.0.0.1", "::1"):
293
+ raise ValueError(
294
+ f"LANE1_NON_LOOPBACK_SOCKET: ComfyUI socket {addr!r} is bound to non-loopback address "
295
+ "(must be 127.0.0.1 or ::1)"
296
+ )
297
+
298
+ return comfy_sockets, other_listeners, socket_match
299
+
300
+
301
+ def verify_loopback_sockets(
302
+ sockets: list[str] | list[dict[str, str]],
303
+ comfy_port: int = 8188,
304
+ *,
305
+ comfy_pid: int | None = None,
306
+ proc_root: Path | None = None,
307
+ ) -> list[str]:
308
+ comfy_sockets, _, _ = inspect_comfy_listeners(
309
+ sockets,
310
+ comfy_port,
311
+ comfy_pid=comfy_pid,
312
+ proc_root=proc_root,
313
+ allow_host_proc=False,
314
+ )
315
+ return comfy_sockets
316
+
317
+
318
+ def probe_listening_socket_records(
319
+ *,
320
+ proc_root: Path | None = None,
321
+ proc_net_tcp: Path | None = None,
322
+ proc_net_tcp6: Path | None = None,
323
+ ss_runner: Any | None = None,
324
+ ) -> list[dict[str, str]]:
325
+ """Read listening TCP socket records (address:port + inode) from /proc/net/tcp* or `ss -tln`."""
326
+ any_proc_override = any(x is not None for x in (proc_root, proc_net_tcp, proc_net_tcp6))
327
+ if any_proc_override:
328
+ tcp_v4 = proc_net_tcp if proc_net_tcp is not None else (proc_root / "net" / "tcp" if proc_root is not None else None)
329
+ tcp_v6 = proc_net_tcp6 if proc_net_tcp6 is not None else (proc_root / "net" / "tcp6" if proc_root is not None else None)
330
+ else:
331
+ tcp_v4 = Path("/proc/net/tcp")
332
+ tcp_v6 = Path("/proc/net/tcp6")
333
+
334
+ found: list[dict[str, str]] = []
335
+ if tcp_v4 is not None and tcp_v4.exists():
336
+ found.extend(parse_proc_net_tcp_records(tcp_v4.read_text(encoding="utf-8", errors="replace"), ipv6=False))
337
+ if tcp_v6 is not None and tcp_v6.exists():
338
+ found.extend(parse_proc_net_tcp_records(tcp_v6.read_text(encoding="utf-8", errors="replace"), ipv6=True))
339
+ if not found:
340
+ if ss_runner is not None:
341
+ ss_out = ss_runner()
342
+ elif not any_proc_override:
343
+ try:
344
+ proc = subprocess.run(["ss", "-tln"], capture_output=True, text=True, timeout=5, check=False)
345
+ ss_out = proc.stdout or ""
346
+ except Exception:
347
+ ss_out = ""
348
+ else:
349
+ ss_out = ""
350
+ for s in parse_ss_tln(ss_out):
351
+ found.append({"address": s, "inode": ""})
352
+ return found
353
+
354
+
355
+ def probe_listening_sockets(
356
+ *,
357
+ proc_root: Path | None = None,
358
+ proc_net_tcp: Path | None = None,
359
+ proc_net_tcp6: Path | None = None,
360
+ ss_runner: Any | None = None,
361
+ ) -> list[str]:
362
+ """Read listening TCP sockets from /proc/net/tcp* or `ss -tln` without synthetic fallback."""
363
+ out: list[str] = []
364
+ for rec in probe_listening_socket_records(
365
+ proc_root=proc_root,
366
+ proc_net_tcp=proc_net_tcp,
367
+ proc_net_tcp6=proc_net_tcp6,
368
+ ss_runner=ss_runner,
369
+ ):
370
+ addr = rec["address"]
371
+ if addr not in out:
372
+ out.append(addr)
373
+ return out
374
+
375
+
376
+ def probe_system_python(
377
+ python_bin: str | Path = "/usr/bin/python3",
378
+ runner: Any | None = None,
379
+ ) -> str:
380
+ """Measure the VM's system Python version via `<python_bin> --version` with no hardcoded default."""
381
+ cmd = [str(python_bin), "--version"]
382
+ if runner is not None:
383
+ raw = str(runner(cmd) or "").strip()
384
+ else:
385
+ try:
386
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=10, check=False)
387
+ raw = ((proc.stdout or "") + "\n" + (proc.stderr or "")).strip()
388
+ except Exception as exc:
389
+ raise ValueError(f"LANE1_NO_MEASURED_SYSTEM_PYTHON: failed to run {cmd!r}: {exc}") from exc
390
+ for token in raw.replace("\n", " ").split():
391
+ parts = token.strip().split(".")
392
+ if len(parts) >= 2 and all(p.isdigit() for p in parts[:2]):
393
+ return token.strip()
394
+ raise ValueError(f"LANE1_NO_MEASURED_SYSTEM_PYTHON: could not parse version from {raw!r}")
395
+
396
+
397
+ def build_text_chunk(keyword: str, text: str) -> bytes:
398
+ payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
399
+ ctype = b"tEXt"
400
+ crc = zlib.crc32(ctype + payload) & 0xFFFFFFFF
401
+ return struct.pack(">I", len(payload)) + ctype + payload + struct.pack(">I", crc)
402
+
403
+
404
+ def write_png_text_chunks(png_bytes: bytes, text_map: dict[str, str]) -> bytes:
405
+ if not png_bytes.startswith(PNG_SIGNATURE):
406
+ raise ValueError("PNG_INVALID: bad PNG signature")
407
+ replace_keys = set(text_map.keys())
408
+ kept: list[bytes] = [PNG_SIGNATURE]
409
+ iend_chunk: bytes | None = None
410
+ offset = len(PNG_SIGNATURE)
411
+ size = len(png_bytes)
412
+
413
+ while offset + 8 <= size:
414
+ (length,) = struct.unpack(">I", png_bytes[offset : offset + 4])
415
+ ctype = png_bytes[offset + 4 : offset + 8]
416
+ chunk_end = offset + 8 + length + 4
417
+ if chunk_end > size:
418
+ raise ValueError(f"PNG_INVALID: truncated {ctype!r} chunk")
419
+ raw_chunk = png_bytes[offset:chunk_end]
420
+ payload = png_bytes[offset + 8 : offset + 8 + length]
421
+ offset = chunk_end
422
+
423
+ if ctype == b"tEXt":
424
+ kw, _, _ = payload.partition(b"\x00")
425
+ if kw.decode("latin-1", errors="replace") in replace_keys:
426
+ continue
427
+ if ctype == b"IEND":
428
+ iend_chunk = raw_chunk
429
+ break
430
+ kept.append(raw_chunk)
431
+
432
+ for k, v in text_map.items():
433
+ if v is not None:
434
+ kept.append(build_text_chunk(k, str(v)))
435
+
436
+ kept.append(iend_chunk or b"\x00\x00\x00\x00IEND\xaeB`\x82")
437
+ return b"".join(kept)
438
+
439
+
440
+ def unwrap_text_val(val: str | None) -> str | None:
441
+ if val is None:
442
+ return None
443
+ t = val.strip()
444
+ if len(t) >= 2 and t.startswith('"') and t.endswith('"'):
445
+ try:
446
+ parsed = json.loads(t)
447
+ if isinstance(parsed, str):
448
+ return parsed
449
+ except Exception:
450
+ return val
451
+ return val
452
+
453
+
454
+ def stamp_png_provenance(png_bytes: bytes, stamp: dict[str, Any]) -> bytes:
455
+ for req in REQUIRED_STAMP_FIELDS:
456
+ v = stamp.get(req)
457
+ if not isinstance(v, str) or not v.strip():
458
+ raise ValueError(f"PNG_STAMP_MISSING: missing required field {req!r}")
459
+ text_map: dict[str, str] = {}
460
+ for field_name, chunk_key in STAMP_KEYS.items():
461
+ v = stamp.get(field_name)
462
+ if isinstance(v, str) and v.strip():
463
+ text_map[chunk_key] = v
464
+ return write_png_text_chunks(png_bytes, text_map)
465
+
466
+
467
+ def verify_png_provenance(png_source: Path | bytes, expected: dict[str, Any]) -> dict[str, str]:
468
+ chunks = read_png_text_chunks(png_source)
469
+ parsed: dict[str, str] = {}
470
+ for field_name, chunk_key in STAMP_KEYS.items():
471
+ raw_val = chunks.get(chunk_key)
472
+ unwrapped = unwrap_text_val(raw_val)
473
+ if unwrapped is not None:
474
+ parsed[field_name] = unwrapped
475
+ for req in REQUIRED_STAMP_FIELDS:
476
+ if not parsed.get(req):
477
+ raise ValueError(f"PNG_STAMP_MISSING: chunk {STAMP_KEYS[req]!r} missing on read-back")
478
+ for field_name, exp_val in expected.items():
479
+ if exp_val is None or field_name not in STAMP_KEYS:
480
+ continue
481
+ if parsed.get(field_name) != str(exp_val):
482
+ raise ValueError(
483
+ f"PNG_STAMP_MISMATCH: {field_name} got {parsed.get(field_name)!r}, expected {exp_val!r}"
484
+ )
485
+ return parsed
486
+
487
+
488
+ def build_qwen_lora_graph(job: dict[str, Any]) -> dict[str, Any]:
489
+ """Build the Qwen-Image LoRA-aware API graph (G3: LoraLoaderModelOnly chain 40.. between 37 and 66)."""
490
+ pins = job.get("model_pins") or {}
491
+ unet = (pins.get("base") or {}).get("name") or "qwen_image_fp8_e4m3fn.safetensors"
492
+ clip = (pins.get("text_encoder") or {}).get("name") or "qwen_2.5_vl_7b_fp8_scaled.safetensors"
493
+ vae = (pins.get("vae") or {}).get("name") or "qwen_image_vae.safetensors"
494
+ loras = job.get("loras") or pins.get("loras") or []
495
+
496
+ nodes: dict[str, Any] = {
497
+ "37": {"class_type": "UNETLoader", "inputs": {"unet_name": unet, "weight_dtype": "default"}},
498
+ "38": {"class_type": "CLIPLoader", "inputs": {"clip_name": clip, "type": "qwen_image"}},
499
+ "39": {"class_type": "VAELoader", "inputs": {"vae_name": vae}},
500
+ "6": {"class_type": "CLIPTextEncode", "inputs": {"text": job.get("prompt", ""), "clip": ["38", 0]}},
501
+ "7": {"class_type": "CLIPTextEncode", "inputs": {"text": job.get("negative", ""), "clip": ["38", 0]}},
502
+ }
503
+ model_out = ["37", 0]
504
+ for idx, lora in enumerate(loras):
505
+ lid = str(40 + idx)
506
+ weight = lora.get("weight", lora.get("strength_model", 1.0))
507
+ nodes[lid] = {
508
+ "class_type": "LoraLoaderModelOnly",
509
+ "inputs": {
510
+ "lora_name": lora.get("name") or lora.get("lora_name"),
511
+ "strength_model": 1.0 if weight is None else float(weight),
512
+ "model": model_out,
513
+ },
514
+ }
515
+ model_out = [lid, 0]
516
+
517
+ nodes.update(
518
+ {
519
+ "66": {"class_type": "ModelSamplingAuraFlow", "inputs": {"shift": float(job.get("shift", 3.1)), "model": model_out}},
520
+ "5": {
521
+ "class_type": "EmptySD3LatentImage",
522
+ "inputs": {"width": int(job.get("width", 1024)), "height": int(job.get("height", 1024)), "batch_size": 1},
523
+ },
524
+ "3": {
525
+ "class_type": "KSampler",
526
+ "inputs": {
527
+ "seed": int(job.get("seed", 0)),
528
+ "steps": int(job.get("steps", 22)),
529
+ "cfg": float(job.get("cfg", 3.5)),
530
+ "sampler_name": job.get("sampler", "euler"),
531
+ "scheduler": job.get("scheduler", "simple"),
532
+ "denoise": 1.0,
533
+ "model": ["66", 0],
534
+ "positive": ["6", 0],
535
+ "negative": ["7", 0],
536
+ "latent_image": ["5", 0],
537
+ },
538
+ },
539
+ "8": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0], "vae": ["39", 0]}},
540
+ "9": {
541
+ "class_type": "SaveImage",
542
+ "inputs": {"filename_prefix": f"sdl_{job.get('job_id', 'job')}", "images": ["8", 0]},
543
+ },
544
+ }
545
+ )
546
+ return nodes
547
+
548
+
549
+ def build_stub_fixture_graph(job: dict[str, Any]) -> dict[str, Any]:
550
+ """Minimal 64x64 no-weights ComfyUI fixture graph (EmptyImage -> ImageInvert -> SaveImage)."""
551
+ seed = int(job.get("seed", 0))
552
+ color = abs(seed) & 0xFFFFFF
553
+ width = int(job.get("width", 64))
554
+ height = int(job.get("height", 64))
555
+ return {
556
+ "1": {"class_type": "EmptyImage", "inputs": {"width": width, "height": height, "batch_size": 1, "color": color}},
557
+ "2": {"class_type": "ImageInvert", "inputs": {"image": ["1", 0]}},
558
+ "3": {
559
+ "class_type": "SaveImage",
560
+ "inputs": {"filename_prefix": f"sdl_{job.get('job_id', 'stub')}", "images": ["2", 0]},
561
+ },
562
+ }
563
+
564
+
565
+ def stage_weights(manifest_path: Path, staging_dir: Path | None = None) -> list[dict[str, Any]]:
566
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
567
+ if not isinstance(manifest, list) or not manifest:
568
+ raise ValueError("LANE1_WEIGHT_MANIFEST_EMPTY: manifest must be a non-empty list")
569
+ staged = []
570
+ for item in manifest:
571
+ name = item.get("name")
572
+ exp_sha = normalize_sha256(item.get("sha256"))
573
+ lic = item.get("licence_id")
574
+ if not name or not exp_sha:
575
+ raise ValueError(f"LANE1_WEIGHT_SHA_MISSING: invalid name or sha256 in {item!r}")
576
+ if lic not in CLEARED_LICENCES or not item.get("licence_url") or not item.get("date_read"):
577
+ raise ValueError(f"LANE1_WEIGHT_LICENCE_INVALID: {name} has uncleared licence {lic!r}")
578
+ local_path = Path(item["local_path"]) if item.get("local_path") else (staging_dir / name if staging_dir else None)
579
+ verified = False
580
+ if local_path is not None:
581
+ if not local_path.exists():
582
+ raise ValueError(f"LANE1_WEIGHT_FILE_MISSING: {local_path} does not exist")
583
+ got_sha = sha256_file(local_path)
584
+ if got_sha != exp_sha:
585
+ raise ValueError(f"LANE1_WEIGHT_SHA_MISMATCH: {name} got {got_sha}, expected {exp_sha}")
586
+ verified = True
587
+ staged.append({**item, "sha256": exp_sha, "verified": verified})
588
+ return staged
589
+
590
+
591
+ def write_window_json(
592
+ window_json_path: Path,
593
+ *,
594
+ window_id: str,
595
+ comfy_url: str,
596
+ system_python: str | None = None,
597
+ sockets: list[str] | None = None,
598
+ comfyui_commit: str | None = None,
599
+ card: str = "CPU",
600
+ cap: float = 0.05,
601
+ wall_s: int = 3000,
602
+ margin: float = 0.01,
603
+ expected_python: str | None = None,
604
+ comfy_pid: int | None = None,
605
+ proc_root: Path | None = None,
606
+ proc_net_tcp: Path | None = None,
607
+ proc_net_tcp6: Path | None = None,
608
+ ss_runner: Any | None = None,
609
+ system_python_bin: str | Path = "/usr/bin/python3",
610
+ system_python_runner: Any | None = None,
611
+ ) -> dict[str, Any]:
612
+ host, port = assert_loopback_url(comfy_url)
613
+ py_ver = sys.version.split()[0]
614
+ exp_py = expected_python or os.environ.get("SDLAB_EXPECTED_PYTHON", PY_VERSION_EXPECTED)
615
+ if py_ver != exp_py:
616
+ raise ValueError(f"LANE1_PYTHON_VERSION_MISMATCH: venv python is {py_ver}, expected {exp_py}")
617
+ py_sha = sha256_file(Path(sys.executable))
618
+ try:
619
+ uv_out = subprocess.run(["uv", "--version"], capture_output=True, text=True, timeout=10, check=False).stdout.strip()
620
+ except Exception:
621
+ uv_out = UV_PIN
622
+ measured_sys_py = (
623
+ system_python.strip()
624
+ if isinstance(system_python, str) and system_python.strip()
625
+ else probe_system_python(python_bin=system_python_bin, runner=system_python_runner)
626
+ )
627
+ raw_records: list[str] | list[dict[str, str]] = (
628
+ list(sockets)
629
+ if sockets is not None
630
+ else probe_listening_socket_records(
631
+ proc_root=proc_root,
632
+ proc_net_tcp=proc_net_tcp,
633
+ proc_net_tcp6=proc_net_tcp6,
634
+ ss_runner=ss_runner,
635
+ )
636
+ )
637
+ if not raw_records:
638
+ raise ValueError(
639
+ "LANE1_NO_MEASURED_SOCKETS: no listening TCP sockets found in /proc/net/tcp* or ss -tln"
640
+ )
641
+ allow_host_proc = all(x is None for x in (proc_root, proc_net_tcp, proc_net_tcp6, sockets))
642
+ comfy_sockets, other_listeners, socket_match = inspect_comfy_listeners(
643
+ raw_records,
644
+ port,
645
+ comfy_pid=comfy_pid,
646
+ proc_root=proc_root,
647
+ allow_host_proc=allow_host_proc,
648
+ )
649
+
650
+ record = {
651
+ "window_id": window_id,
652
+ "lane": "render",
653
+ "card": card,
654
+ "vm_gate": {
655
+ "cuda_visible_devices": os.environ.get("CUDA_VISIBLE_DEVICES", ""),
656
+ "platform": sys.platform,
657
+ },
658
+ "system_python": measured_sys_py,
659
+ "python": py_ver,
660
+ "python_sha256": py_sha,
661
+ "uv": uv_out or UV_PIN,
662
+ "comfyui_commit": comfyui_commit,
663
+ "comfy_headless_version": COMFY_HEADLESS_VERSION,
664
+ "comfy_bind": f"{host}:{port}",
665
+ "comfy_pid": comfy_pid,
666
+ "socket_match": socket_match,
667
+ "listening_sockets": comfy_sockets,
668
+ "other_listeners": other_listeners,
669
+ "override_sha256": None,
670
+ "cap": cap,
671
+ "wall_s": wall_s,
672
+ "margin": margin,
673
+ "start_balance": None,
674
+ "end_balance": None,
675
+ "rate_measured": None,
676
+ "opened_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
677
+ "closed_at": None,
678
+ }
679
+ window_json_path.parent.mkdir(parents=True, exist_ok=True)
680
+ window_json_path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
681
+ return record
682
+
683
+
684
+ def download_comfy_image(comfy_url: str, filename: str, subfolder: str = "", img_type: str = "output") -> bytes:
685
+ qs = urllib.parse.urlencode({"filename": filename, "subfolder": subfolder, "type": img_type})
686
+ url = f"{comfy_url.rstrip('/')}/view?{qs}"
687
+ with urllib.request.urlopen(url, timeout=30) as resp:
688
+ return resp.read()
689
+
690
+
691
+ def pick_history_image(history_entry: dict[str, Any]) -> dict[str, str]:
692
+ outputs = history_entry.get("outputs") or {}
693
+ for node_id in sorted(outputs.keys(), key=lambda k: int(k) if str(k).isdigit() else 0, reverse=True):
694
+ images = (outputs.get(node_id) or {}).get("images") or []
695
+ if images:
696
+ return images[0]
697
+ raise RuntimeError(f"No output images in ComfyUI history entry: {history_entry!r}")
698
+
699
+
700
+ def load_completed(renders_path: Path, out_dir: Path) -> dict[str, dict[str, Any]]:
701
+ done: dict[str, dict[str, Any]] = {}
702
+ if not renders_path.exists():
703
+ return done
704
+ for line in renders_path.read_text(encoding="utf-8").splitlines():
705
+ line = line.strip()
706
+ if not line:
707
+ continue
708
+ row = json.loads(line)
709
+ if row.get("status") == "ok" and row.get("job_id") and row.get("output_file"):
710
+ png_path = out_dir / row["output_file"]
711
+ if png_path.exists() and sha256_file(png_path) == row.get("png_sha256"):
712
+ try:
713
+ verify_png_provenance(
714
+ png_path,
715
+ {
716
+ "owner": row.get("owner"),
717
+ "licence": row.get("licence"),
718
+ "canon_sha": row.get("canon_sha"),
719
+ "entry_id": row.get("entry_id"),
720
+ "entry_sha256": row.get("entry_sha256"),
721
+ "run_id": row.get("run_id"),
722
+ },
723
+ )
724
+ done[row["job_id"]] = row
725
+ except Exception:
726
+ pass
727
+ return done
728
+
729
+
730
+ def sync_copy_back(renders_path: Path, current_json: Path, out_dir: Path, copy_back_dir: Path, files: set[str]) -> None:
731
+ copy_back_dir.mkdir(parents=True, exist_ok=True)
732
+ if renders_path.exists():
733
+ shutil.copy2(renders_path, copy_back_dir / "renders.jsonl")
734
+ if current_json.exists():
735
+ shutil.copy2(current_json, copy_back_dir / "current.json")
736
+ for fname in sorted(files):
737
+ src = out_dir / fname
738
+ if src.exists():
739
+ shutil.copy2(src, copy_back_dir / fname)
740
+
741
+
742
+ def run_queue(
743
+ *,
744
+ jobs_path: Path,
745
+ renders_path: Path,
746
+ out_dir: Path,
747
+ comfy_url: str,
748
+ window_id: str,
749
+ copy_back_dir: Path | None = None,
750
+ copy_back_every: int = 5,
751
+ kill_after: int | None = None,
752
+ client: Any | None = None,
753
+ image_downloader: Any | None = None,
754
+ system_stats_fetcher: Any | None = None,
755
+ ) -> dict[str, Any]:
756
+ assert_loopback_url(comfy_url)
757
+ out_dir.mkdir(parents=True, exist_ok=True)
758
+ renders_path.parent.mkdir(parents=True, exist_ok=True)
759
+ current_json = renders_path.parent / "current.json"
760
+
761
+ # Query /system_stats to log device & prove CPU execution
762
+ if system_stats_fetcher is not None:
763
+ sys_stats = system_stats_fetcher(comfy_url)
764
+ else:
765
+ with urllib.request.urlopen(f"{comfy_url.rstrip('/')}/system_stats", timeout=15) as resp:
766
+ sys_stats = json.loads(resp.read().decode("utf-8"))
767
+ print(f"COMFY_SYSTEM_STATS {json.dumps(sys_stats)}", flush=True)
768
+
769
+ active_client = client if client is not None else ComfyClient(base_url=comfy_url)
770
+ active_downloader = image_downloader if image_downloader is not None else download_comfy_image
771
+ jobs = [json.loads(line) for line in jobs_path.read_text(encoding="utf-8").splitlines() if line.strip()]
772
+ completed = load_completed(renders_path, out_dir)
773
+ completed_files = {r["output_file"] for r in completed.values()}
774
+
775
+ skipped_count = 0
776
+ rendered_count = 0
777
+ copy_back_count = 0
778
+ killed_early = False
779
+
780
+ for job in jobs:
781
+ job_id = job["job_id"]
782
+ if job_id in completed:
783
+ print(f"SKIP {job_id} (already in renders.jsonl with verified PNG stamp and sha256)", flush=True)
784
+ skipped_count += 1
785
+ continue
786
+
787
+ t0 = time.time()
788
+ workflow = job.get("workflow")
789
+ if not workflow:
790
+ if job.get("template_id") == "stub_fixture_64":
791
+ workflow = build_stub_fixture_graph(job)
792
+ else:
793
+ workflow = build_qwen_lora_graph(job)
794
+
795
+ stamp_meta = {
796
+ "owner": job.get("owner", "mcp-tool-shop"),
797
+ "licence": job.get("licence", "CC-BY-4.0"),
798
+ "canon_sha": job["canon_sha"],
799
+ "entry_id": job["entry_id"],
800
+ "entry_sha256": job["entry_sha256"],
801
+ "run_id": job["run_id"],
802
+ "workflow_sha": job.get("workflow_sha", ""),
803
+ "window_id": window_id,
804
+ }
805
+ extra_pnginfo = {STAMP_KEYS[k]: v for k, v in stamp_meta.items() if v}
806
+
807
+ prompt_id = active_client.queue_prompt(workflow, extra_pnginfo=extra_pnginfo)
808
+ if not prompt_id:
809
+ raise RuntimeError(f"Failed to queue prompt for job {job_id}")
810
+ history_entry = active_client.wait_for_completion(prompt_id, timeout=120.0, poll_interval=0.2)
811
+ if not history_entry:
812
+ raise RuntimeError(f"Timed out waiting for prompt {prompt_id} (job {job_id})")
813
+
814
+ img_info = pick_history_image(history_entry)
815
+ raw_png = active_downloader(
816
+ comfy_url,
817
+ img_info["filename"],
818
+ img_info.get("subfolder", ""),
819
+ img_info.get("type", "output"),
820
+ )
821
+
822
+ # Verify ComfyUI embedded the runnable prompt graph via comfy_headless
823
+ wf_meta = read_workflow_metadata(raw_png)
824
+ if not wf_meta.has_prompt:
825
+ raise RuntimeError(f"ComfyUI output for {job_id} missing embedded prompt graph")
826
+
827
+ # Write canonical tEXt provenance stamp and immediately read back from disk (G8, H7)
828
+ stamped_png = stamp_png_provenance(raw_png, stamp_meta)
829
+ output_file = f"{job_id}.png"
830
+ out_path = out_dir / output_file
831
+ out_path.write_bytes(stamped_png)
832
+
833
+ verified_stamp = verify_png_provenance(out_path, stamp_meta)
834
+ png_sha = sha256_file(out_path)
835
+ wall_ms = round((time.time() - t0) * 1000)
836
+
837
+ row = {
838
+ "job_id": job_id,
839
+ "run_id": job["run_id"],
840
+ "status": "ok",
841
+ "output_file": output_file,
842
+ "png_sha256": png_sha,
843
+ "workflow_sha": job.get("workflow_sha"),
844
+ "seed": job.get("seed"),
845
+ "wall_ms": wall_ms,
846
+ "window_id": window_id,
847
+ "canon_sha": job["canon_sha"],
848
+ "entry_id": job["entry_id"],
849
+ "entry_sha256": job["entry_sha256"],
850
+ "owner": verified_stamp["owner"],
851
+ "licence": verified_stamp["licence"],
852
+ "model_pins": job.get("model_pins"),
853
+ "stamp_verified": True,
854
+ }
855
+ with renders_path.open("a", encoding="utf-8") as rf:
856
+ rf.write(json.dumps(row) + "\n")
857
+
858
+ completed_files.add(output_file)
859
+ rendered_count += 1
860
+ print(f"RENDERED {job_id} -> {output_file} ({png_sha}, {wall_ms}ms, stamp_verified=True)", flush=True)
861
+
862
+ current_json.write_text(
863
+ json.dumps(
864
+ {
865
+ "window_id": window_id,
866
+ "run_id": job["run_id"],
867
+ "last_job_id": job_id,
868
+ "files": ["renders.jsonl", *sorted(completed_files)],
869
+ },
870
+ indent=2,
871
+ )
872
+ + "\n",
873
+ encoding="utf-8",
874
+ )
875
+
876
+ if copy_back_dir and copy_back_every > 0 and rendered_count % copy_back_every == 0:
877
+ sync_copy_back(renders_path, current_json, out_dir, copy_back_dir, completed_files)
878
+ copy_back_count += 1
879
+
880
+ if kill_after is not None and rendered_count >= kill_after:
881
+ print(f"DELIBERATE_KILL after {rendered_count} job(s)", flush=True)
882
+ killed_early = True
883
+ break
884
+
885
+ if copy_back_dir and not killed_early and rendered_count > 0 and rendered_count % copy_back_every != 0:
886
+ sync_copy_back(renders_path, current_json, out_dir, copy_back_dir, completed_files)
887
+ copy_back_count += 1
888
+
889
+ summary = {
890
+ "total_jobs": len(jobs),
891
+ "rendered": rendered_count,
892
+ "skipped": skipped_count,
893
+ "copy_backs": copy_back_count,
894
+ "killed_early": killed_early,
895
+ }
896
+ print(f"SUMMARY {json.dumps(summary)}", flush=True)
897
+ return summary
898
+
899
+
900
+ def main(argv: list[str] | None = None) -> int:
901
+ parser = argparse.ArgumentParser(description="sdlab Lane 1 headless ComfyUI render driver")
902
+ parser.add_argument("--cpu", action="store_true", help="Force CPU-only mode (CUDA_VISIBLE_DEVICES='')")
903
+ parser.add_argument("--bootstrap", action="store_true", help="Write window.json and verify weight manifest")
904
+ parser.add_argument("--window-json", type=Path, help="Path to write window.json")
905
+ parser.add_argument("--window-id", default="win_lane1_stub_01", help="Window identifier")
906
+ parser.add_argument("--system-python", default=None, help="Measured host/system Python version (probed when omitted)")
907
+ parser.add_argument("--system-python-bin", default="/usr/bin/python3", help="System python binary to probe")
908
+ parser.add_argument("--comfy-pid", type=int, default=None, help="PID of the ComfyUI process started by this kit")
909
+ parser.add_argument("--proc-root", type=Path, default=None, help="Optional /proc root directory override")
910
+ parser.add_argument("--proc-net-tcp", type=Path, default=None, help="Optional /proc/net/tcp path override")
911
+ parser.add_argument("--proc-net-tcp6", type=Path, default=None, help="Optional /proc/net/tcp6 path override")
912
+ parser.add_argument("--comfyui-commit", default=None, help="Pinned ComfyUI git commit SHA")
913
+ parser.add_argument("--weight-manifest", type=Path, help="Optional weight manifest JSON to verify by sha256 + licence")
914
+ parser.add_argument("--staging-dir", type=Path, help="Optional weight staging directory")
915
+ parser.add_argument("--comfy-url", default="http://127.0.0.1:8188", help="Loopback ComfyUI URL")
916
+ parser.add_argument("--jobs", type=Path, help="Path to jobs.jsonl")
917
+ parser.add_argument("--renders", type=Path, help="Path to renders.jsonl")
918
+ parser.add_argument("--out-dir", type=Path, help="Directory for rendered PNG files")
919
+ parser.add_argument("--copy-back-dir", type=Path, help="Optional periodic copy-back directory")
920
+ parser.add_argument("--copy-back-every", type=int, default=2, help="Copy-back interval (number of renders)")
921
+ parser.add_argument("--kill-after", type=int, default=None, help="Stop after K rendered jobs to test resume")
922
+ args = parser.parse_args(argv)
923
+
924
+ if args.cpu:
925
+ os.environ["CUDA_VISIBLE_DEVICES"] = ""
926
+
927
+ print(
928
+ f"LANE1_START python={sys.version.split()[0]} comfy_headless={COMFY_HEADLESS_VERSION} "
929
+ f"CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', '')!r}",
930
+ flush=True,
931
+ )
932
+
933
+ if args.weight_manifest:
934
+ staged = stage_weights(args.weight_manifest, args.staging_dir)
935
+ print(f"WEIGHTS_STAGED count={len(staged)}", flush=True)
936
+
937
+ if args.bootstrap and args.window_json:
938
+ rec = write_window_json(
939
+ args.window_json,
940
+ window_id=args.window_id,
941
+ comfy_url=args.comfy_url,
942
+ system_python=args.system_python,
943
+ comfyui_commit=args.comfyui_commit,
944
+ comfy_pid=args.comfy_pid,
945
+ proc_root=args.proc_root,
946
+ proc_net_tcp=args.proc_net_tcp,
947
+ proc_net_tcp6=args.proc_net_tcp6,
948
+ system_python_bin=args.system_python_bin,
949
+ )
950
+ print(f"WINDOW_JSON {json.dumps(rec)}", flush=True)
951
+
952
+ if args.jobs and args.renders and args.out_dir:
953
+ summary = run_queue(
954
+ jobs_path=args.jobs,
955
+ renders_path=args.renders,
956
+ out_dir=args.out_dir,
957
+ comfy_url=args.comfy_url,
958
+ window_id=args.window_id,
959
+ copy_back_dir=args.copy_back_dir,
960
+ copy_back_every=args.copy_back_every,
961
+ kill_after=args.kill_after,
962
+ )
963
+ if summary["killed_early"]:
964
+ return 75
965
+
966
+ return 0
967
+
968
+
969
+ if __name__ == "__main__":
970
+ raise SystemExit(main())