@mcptoolshop/style-dataset-lab 3.4.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.
- package/CHANGELOG.md +27 -0
- package/README.es.md +111 -59
- package/README.fr.md +117 -67
- package/README.hi.md +109 -70
- package/README.it.md +111 -60
- package/README.ja.md +116 -76
- package/README.md +58 -2
- package/README.pt-BR.md +119 -64
- package/README.zh.md +108 -67
- package/bin/sdlab.js +50 -1
- package/lib/adapters/ai-toolkit.js +187 -30
- package/lib/adapters/comfyui-workflows.js +84 -9
- package/lib/asset-ingest.js +575 -0
- package/lib/asset-masks.js +323 -0
- package/lib/asset-source.js +1176 -0
- package/lib/canon-author.js +350 -0
- package/lib/canon-review-gate.js +205 -0
- package/lib/colab-launcher-guards.js +285 -0
- package/lib/export.js +1 -1
- package/lib/freeze-gate.js +3 -0
- package/lib/generated-provenance.js +178 -5
- package/lib/lane1-render.js +668 -0
- package/lib/memory-store.js +663 -0
- package/lib/model-licences.js +305 -0
- package/lib/npy-meta.js +130 -0
- package/lib/png-meta.js +512 -0
- package/lib/records.js +54 -8
- package/lib/reingest-selected.js +15 -1
- package/lib/reingest.js +27 -3
- package/lib/run-manifest.js +59 -11
- package/lib/train-runs.js +214 -0
- package/lib/verifier.js +3 -0
- package/lib/verify-chain.js +367 -0
- package/package.json +2 -2
- package/scripts/asset-ingest.js +160 -0
- package/scripts/canon-draft.js +82 -0
- package/scripts/canon-freeze.js +4 -0
- package/scripts/colab/REAL_SERVICE_ASSUMPTIONS.md +14 -0
- package/scripts/colab/ai-toolkit-override-py315.txt +45 -0
- package/scripts/colab/colab_window.sh +587 -0
- package/scripts/colab/lane1_render.py +970 -0
- package/scripts/colab/launch_guards.py +564 -0
- package/scripts/colab/launch_lane1_stub.sh +309 -0
- package/scripts/colab/launch_lane3_stub.sh +366 -0
- package/scripts/colab/remote_runner.py +59 -0
- package/scripts/doctor.js +1 -0
- package/scripts/generate.js +38 -12
- package/scripts/verify.js +67 -0
|
@@ -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())
|