frutebox-node 0.1.0__py3-none-any.whl
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.
- frutebox/adminheader.py +59 -0
- frutebox/attest.py +43 -0
- frutebox/audiogen.py +364 -0
- frutebox/config.py +367 -0
- frutebox/deltasync.py +211 -0
- frutebox/economics.py +270 -0
- frutebox/engine_guard.py +718 -0
- frutebox/envelope.py +77 -0
- frutebox/envvars.py +167 -0
- frutebox/gpuprices.py +641 -0
- frutebox/gpuproviders.py +4489 -0
- frutebox/gui.py +270 -0
- frutebox/imagegen.py +152 -0
- frutebox/inference.py +624 -0
- frutebox/manifest.py +218 -0
- frutebox/mediaserving.py +603 -0
- frutebox/packaging.py +542 -0
- frutebox/pqctransport.py +660 -0
- frutebox/provider.py +1867 -0
- frutebox/providers.py +942 -0
- frutebox/registry/__init__.py +189 -0
- frutebox/registry/generate.py +311 -0
- frutebox/registry/generate_providers.py +120 -0
- frutebox/registry/models.yaml +1759 -0
- frutebox/registry/providers.yaml +518 -0
- frutebox/registry/registry.gen.json +2043 -0
- frutebox/registry_gen.py +3 -0
- frutebox/runpod_templates.py +143 -0
- frutebox/serving.py +691 -0
- frutebox/signedconfig.py +259 -0
- frutebox/signing.py +208 -0
- frutebox/thinkbudget.py +155 -0
- frutebox/videogen.py +118 -0
- frutebox_node-0.1.0.dist-info/METADATA +100 -0
- frutebox_node-0.1.0.dist-info/RECORD +36 -0
- frutebox_node-0.1.0.dist-info/WHEEL +4 -0
frutebox/gui.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""A modern, local web dashboard the node software serves to its owner.
|
|
2
|
+
|
|
3
|
+
This is deliberately a *web* GUI (served on localhost), not a desktop-widget
|
|
4
|
+
toolkit — so it looks clean and modern in any browser, matching the website, and
|
|
5
|
+
never resembles an old Tkinter/Swing window. The contributor opens it to see, in
|
|
6
|
+
plain terms: is my machine running, what is it doing, and what have I earned.
|
|
7
|
+
|
|
8
|
+
start_gui(stats_fn, kill_fn, port) -> runs a tiny HTTP server in a daemon
|
|
9
|
+
thread. stats_fn() returns a dict; kill_fn() stops the node (the "kill switch").
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hmac
|
|
14
|
+
import ipaddress
|
|
15
|
+
import json
|
|
16
|
+
import secrets
|
|
17
|
+
import threading
|
|
18
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
# CSRF token for the destructive endpoint. `POST /kill` used to be unauthenticated on
|
|
22
|
+
# a predictable loopback port, so ANY web page the owner happened to visit could stop
|
|
23
|
+
# their node with a cross-origin form/fetch — no reply needed, so the same-origin
|
|
24
|
+
# policy did not help. The token is rendered into the page the server itself serves;
|
|
25
|
+
# a foreign page can issue the POST but cannot read the token to put in it.
|
|
26
|
+
#
|
|
27
|
+
# The token alone was not enough. A browser will happily treat a page on `evil.tld` as
|
|
28
|
+
# same-origin with this server once `evil.tld` re-resolves to 127.0.0.1 (DNS rebinding),
|
|
29
|
+
# and at that point it can simply READ the token out of the page the server renders. So
|
|
30
|
+
# every request is first pinned to a loopback `Host` — see `_loopback_host`.
|
|
31
|
+
#
|
|
32
|
+
# Scope, honestly: this stops drive-by CSRF and rebinding from the browser. It does NOT
|
|
33
|
+
# stop another process already running as the same user — that process can read the page
|
|
34
|
+
# (or just signal the node), and no token can change that.
|
|
35
|
+
_KILL_TOKEN_HEADER = "X-Stevai-Kill"
|
|
36
|
+
|
|
37
|
+
_PAGE = """<!doctype html>
|
|
38
|
+
<html lang="en"><head>
|
|
39
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
40
|
+
<title>Frutebox — your machine</title>
|
|
41
|
+
<style>
|
|
42
|
+
:root{--indigo:#4f46e5;--green:#22c55e;--ink:#10233b;--muted:#5b6b80}
|
|
43
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
44
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
|
45
|
+
color:var(--ink);background:linear-gradient(160deg,#eef2ff 0%,#f7f9fc 40%,#ecfdf5 100%);
|
|
46
|
+
min-height:100vh;padding:32px 20px}
|
|
47
|
+
.wrap{max-width:920px;margin:0 auto}
|
|
48
|
+
.top{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:28px}
|
|
49
|
+
.brand{font-weight:800;font-size:22px;letter-spacing:-.02em;
|
|
50
|
+
background:linear-gradient(90deg,#4f46e5,#22c55e);-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
51
|
+
.pill{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;
|
|
52
|
+
padding:7px 14px;border-radius:999px;background:#fff;border:1px solid rgba(16,35,59,.1)}
|
|
53
|
+
.dot{width:9px;height:9px;border-radius:50%;background:#cbd5e1}
|
|
54
|
+
.dot.on{background:var(--green);box-shadow:0 0 0 4px rgba(34,197,94,.18)}
|
|
55
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:16px}
|
|
56
|
+
.card{background:rgba(255,255,255,.72);backdrop-filter:blur(10px);border:1px solid rgba(16,35,59,.08);
|
|
57
|
+
border-radius:20px;padding:22px;box-shadow:0 12px 40px -24px rgba(16,35,59,.4)}
|
|
58
|
+
.label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
|
|
59
|
+
.big{font-size:34px;font-weight:800;letter-spacing:-.02em;margin-top:6px}
|
|
60
|
+
.sub{font-size:13px;color:var(--muted);margin-top:4px}
|
|
61
|
+
.row{display:flex;justify-content:space-between;font-size:14px;padding:7px 0;border-bottom:1px solid rgba(16,35,59,.06)}
|
|
62
|
+
.row:last-child{border-bottom:0}
|
|
63
|
+
.row b{font-weight:600}
|
|
64
|
+
.earn{grid-column:1/-1;background:linear-gradient(120deg,#4f46e5,#22c55e);color:#fff;border:0}
|
|
65
|
+
.earn .label{color:rgba(255,255,255,.85)}
|
|
66
|
+
.earn .sub{color:rgba(255,255,255,.85)}
|
|
67
|
+
.kill{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap}
|
|
68
|
+
.kill h3{font-size:17px}.kill p{font-size:13px;color:var(--muted);margin-top:4px;max-width:52ch}
|
|
69
|
+
.kbtn{border:0;cursor:pointer;font-weight:700;font-size:14px;color:#fff;padding:13px 22px;border-radius:999px;
|
|
70
|
+
background:linear-gradient(90deg,#ef4444,#dc2626);box-shadow:0 10px 24px -12px rgba(220,38,38,.8)}
|
|
71
|
+
.kbtn:disabled{opacity:.5;cursor:default}
|
|
72
|
+
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:26px}
|
|
73
|
+
</style></head>
|
|
74
|
+
<body><div class="wrap">
|
|
75
|
+
<div class="top">
|
|
76
|
+
<div class="brand">frutebox</div>
|
|
77
|
+
<div class="pill"><span id="dot" class="dot"></span><span id="statusText">Connecting…</span></div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="grid">
|
|
80
|
+
<div class="card">
|
|
81
|
+
<div class="label">This machine</div>
|
|
82
|
+
<div class="big" id="name">—</div>
|
|
83
|
+
<div class="sub" id="hw">—</div>
|
|
84
|
+
<div style="margin-top:14px">
|
|
85
|
+
<div class="row"><span>Location</span><b id="region">—</b></div>
|
|
86
|
+
<div class="row"><span>Type</span><b id="type">—</b></div>
|
|
87
|
+
<div class="row"><span>Current load</span><b id="load">—</b></div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="card">
|
|
91
|
+
<div class="label">Activity</div>
|
|
92
|
+
<div class="big" id="jobs">0</div>
|
|
93
|
+
<div class="sub">tasks completed</div>
|
|
94
|
+
<div style="margin-top:14px">
|
|
95
|
+
<div class="row"><span>Running right now</span><b id="active">0</b></div>
|
|
96
|
+
<div class="row"><span>Work processed</span><b id="tokens">0</b></div>
|
|
97
|
+
<div class="row"><span>Uptime</span><b id="uptime">—</b></div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="card earn">
|
|
101
|
+
<div class="label">Earned so far (estimate)</div>
|
|
102
|
+
<div class="big" id="earn">0.00</div>
|
|
103
|
+
<div class="sub">Based on the power & internet your machine has contributed. Payouts switch on soon.</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="card kill">
|
|
106
|
+
<div>
|
|
107
|
+
<h3>Stop sharing</h3>
|
|
108
|
+
<p>Instantly stop this machine and clear its workspace. You're always in control — turn it back on whenever you like.</p>
|
|
109
|
+
</div>
|
|
110
|
+
<button class="kbtn" id="kill" onclick="kill()">Stop & clear now</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="foot">Runs only on this machine. It reaches out to the network — it never opens your computer to the outside.</div>
|
|
114
|
+
</div>
|
|
115
|
+
<script>
|
|
116
|
+
const KILL_TOKEN=__KILL_TOKEN__;
|
|
117
|
+
function fmtUptime(s){s=Math.floor(s);const h=Math.floor(s/3600),m=Math.floor(s%3600/60);return h+"h "+m+"m"}
|
|
118
|
+
async function tick(){
|
|
119
|
+
try{
|
|
120
|
+
const r=await fetch('/status',{cache:'no-store'});const d=await r.json();
|
|
121
|
+
document.getElementById('name').textContent=d.name;
|
|
122
|
+
document.getElementById('hw').textContent=d.hardware;
|
|
123
|
+
document.getElementById('region').textContent=d.region;
|
|
124
|
+
document.getElementById('type').textContent=d.type;
|
|
125
|
+
document.getElementById('load').textContent=Math.round(d.load*100)+'%';
|
|
126
|
+
document.getElementById('jobs').textContent=d.jobs;
|
|
127
|
+
document.getElementById('active').textContent=d.active;
|
|
128
|
+
document.getElementById('tokens').textContent=d.tokens.toLocaleString();
|
|
129
|
+
document.getElementById('uptime').textContent=fmtUptime(d.uptime);
|
|
130
|
+
document.getElementById('earn').textContent=d.earnings.toFixed(2);
|
|
131
|
+
const on=d.connected;
|
|
132
|
+
document.getElementById('dot').className='dot'+(on?' on':'');
|
|
133
|
+
document.getElementById('statusText').textContent=on?'Live — earning':'Connecting…';
|
|
134
|
+
}catch(e){document.getElementById('statusText').textContent='Offline'}
|
|
135
|
+
}
|
|
136
|
+
async function kill(){
|
|
137
|
+
if(!confirm('Stop sharing this machine now? It will disconnect and clear its workspace.'))return;
|
|
138
|
+
document.getElementById('kill').disabled=true;
|
|
139
|
+
document.getElementById('kill').textContent='Stopping…';
|
|
140
|
+
try{await fetch('/kill',{method:'POST',headers:{'X-Stevai-Kill':KILL_TOKEN}})}catch(e){}
|
|
141
|
+
document.getElementById('statusText').textContent='Stopped';
|
|
142
|
+
document.getElementById('dot').className='dot';
|
|
143
|
+
}
|
|
144
|
+
tick();setInterval(tick,2000);
|
|
145
|
+
</script>
|
|
146
|
+
</body></html>"""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
150
|
+
stats_fn: Callable[[], dict]
|
|
151
|
+
kill_fn: Callable[[], None]
|
|
152
|
+
kill_token: str = ""
|
|
153
|
+
|
|
154
|
+
def _send(self, code: int, body: bytes, ctype: str) -> None:
|
|
155
|
+
self.send_response(code)
|
|
156
|
+
self.send_header("Content-Type", ctype)
|
|
157
|
+
self.send_header("Content-Length", str(len(body)))
|
|
158
|
+
self.send_header("Cache-Control", "no-store")
|
|
159
|
+
# No CORS headers on purpose: a foreign page may fire a request but must never
|
|
160
|
+
# be able to read this server's replies.
|
|
161
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
162
|
+
self.end_headers()
|
|
163
|
+
self.wfile.write(body)
|
|
164
|
+
|
|
165
|
+
def _loopback_host(self) -> bool:
|
|
166
|
+
"""Is the request addressed to this server by a LOOPBACK name?
|
|
167
|
+
|
|
168
|
+
This is the DNS-rebinding defence, and it has to come before everything else,
|
|
169
|
+
because everything else is derived from the request's own headers. `evil.tld`
|
|
170
|
+
with a 1-second TTL that re-resolves to 127.0.0.1 is *same-origin to the
|
|
171
|
+
browser*: the page could fetch `/`, read `const KILL_TOKEN=…` straight out of
|
|
172
|
+
the HTML this server renders, and POST it back — the token and the Origin check
|
|
173
|
+
both pass, because `_same_origin` built the expected origin from the attacker's
|
|
174
|
+
own Host header. The browser will not lie about Host, so this is the one header
|
|
175
|
+
that pins the request to the address the owner's own tab uses.
|
|
176
|
+
"""
|
|
177
|
+
raw = (self.headers.get("Host") or "").strip()
|
|
178
|
+
if not raw:
|
|
179
|
+
return False # HTTP/1.1 requires Host; no Host, no service
|
|
180
|
+
if raw.startswith("["): # [::1]:8790
|
|
181
|
+
host = raw[1:raw.index("]")] if "]" in raw else ""
|
|
182
|
+
else:
|
|
183
|
+
host = raw.rsplit(":", 1)[0] if ":" in raw else raw
|
|
184
|
+
host = host.lower()
|
|
185
|
+
if host in ("localhost", "localhost.localdomain"):
|
|
186
|
+
return True
|
|
187
|
+
try:
|
|
188
|
+
return ipaddress.ip_address(host).is_loopback
|
|
189
|
+
except ValueError:
|
|
190
|
+
return False # any other name → rebinding, refuse
|
|
191
|
+
|
|
192
|
+
def _same_origin(self) -> bool:
|
|
193
|
+
"""Reject a cross-site Origin outright. Belt to the token's braces, and it also
|
|
194
|
+
covers the no-preflight form-POST shape a CSRF page would use.
|
|
195
|
+
|
|
196
|
+
Only meaningful once `_loopback_host` has passed — the expected origin is built
|
|
197
|
+
from the request's Host, so an unpinned Host makes this check self-satisfying.
|
|
198
|
+
"""
|
|
199
|
+
origin = self.headers.get("Origin")
|
|
200
|
+
if not origin:
|
|
201
|
+
return True # non-browser client (curl, the desktop shell) — token still required
|
|
202
|
+
want = f"http://{self.headers.get('Host', '')}"
|
|
203
|
+
return origin == want or origin == want.replace("http://", "https://")
|
|
204
|
+
|
|
205
|
+
def _kill_authorized(self) -> bool:
|
|
206
|
+
supplied = self.headers.get(_KILL_TOKEN_HEADER, "")
|
|
207
|
+
return bool(self.kill_token) and hmac.compare_digest(supplied, self.kill_token)
|
|
208
|
+
|
|
209
|
+
def do_GET(self) -> None: # noqa: N802
|
|
210
|
+
# Before ANYTHING is served — the page carries the kill token, and /status
|
|
211
|
+
# carries earnings, load and the trust label.
|
|
212
|
+
if not self._loopback_host():
|
|
213
|
+
self._send(403, b"bad host", "text/plain")
|
|
214
|
+
return
|
|
215
|
+
if self.path.startswith("/status"):
|
|
216
|
+
self._send(200, json.dumps(self.stats_fn()).encode(), "application/json")
|
|
217
|
+
elif self.path in ("/", "/index.html"):
|
|
218
|
+
page = _PAGE.replace("__KILL_TOKEN__", json.dumps(self.kill_token))
|
|
219
|
+
self._send(200, page.encode(), "text/html; charset=utf-8")
|
|
220
|
+
else:
|
|
221
|
+
self._send(404, b"not found", "text/plain")
|
|
222
|
+
|
|
223
|
+
def do_POST(self) -> None: # noqa: N802
|
|
224
|
+
if not self._loopback_host():
|
|
225
|
+
self._send(403, b"bad host", "text/plain")
|
|
226
|
+
return
|
|
227
|
+
if not self.path.startswith("/kill"):
|
|
228
|
+
self._send(404, b"not found", "text/plain")
|
|
229
|
+
return
|
|
230
|
+
if not self._same_origin() or not self._kill_authorized():
|
|
231
|
+
self._send(403, b'{"ok":false,"error":"kill token required"}', "application/json")
|
|
232
|
+
return
|
|
233
|
+
# STOP FIRST, THEN SAY SO. This used to answer `{"ok":true}` and call `kill_fn()` afterwards,
|
|
234
|
+
# which is the wrong order for the one control where it matters most: the owner was told their
|
|
235
|
+
# node had stopped sharing BEFORE anything had stopped, and if the stop then raised they were
|
|
236
|
+
# told it succeeded anyway. A kill switch that reports success it has not achieved is worse
|
|
237
|
+
# than one that fails loudly.
|
|
238
|
+
#
|
|
239
|
+
# It was also a race the tests saw: the client could read the 200 and check the flag before
|
|
240
|
+
# the server thread reached the call. That flakiness was the defect showing itself.
|
|
241
|
+
try:
|
|
242
|
+
self.kill_fn()
|
|
243
|
+
except Exception as e: # noqa: BLE001 - the owner must hear about this
|
|
244
|
+
self._send(500, json.dumps(
|
|
245
|
+
{"ok": False, "error": f"could not stop sharing: {type(e).__name__}"}).encode(),
|
|
246
|
+
"application/json")
|
|
247
|
+
return
|
|
248
|
+
self._send(200, b'{"ok":true}', "application/json")
|
|
249
|
+
|
|
250
|
+
def log_message(self, *args) -> None: # keep the console quiet
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def start_gui(stats_fn: Callable[[], dict], kill_fn: Callable[[], None], port: int,
|
|
255
|
+
host: str = "127.0.0.1") -> str:
|
|
256
|
+
"""Serve the owner's dashboard on loopback and return its URL.
|
|
257
|
+
|
|
258
|
+
The kill token is minted per process and rendered into the page, never put in the
|
|
259
|
+
URL (URLs leak through history, referrers and shoulders).
|
|
260
|
+
"""
|
|
261
|
+
token = secrets.token_urlsafe(24)
|
|
262
|
+
handler = type("Handler", (_Handler,), {
|
|
263
|
+
"stats_fn": staticmethod(stats_fn),
|
|
264
|
+
"kill_fn": staticmethod(kill_fn),
|
|
265
|
+
"kill_token": token,
|
|
266
|
+
})
|
|
267
|
+
server = ThreadingHTTPServer((host, port), handler)
|
|
268
|
+
actual_port = server.server_address[1] # resolves port 0 to the real one
|
|
269
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
270
|
+
return f"http://{host}:{actual_port}"
|
frutebox/imagegen.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Image generation — runs on a GPU node. Diffusers/torch are heavy and only present
|
|
2
|
+
on an image-capable node, so they're imported lazily; on any other machine `available()`
|
|
3
|
+
is False and the gateway simply never routes image jobs here.
|
|
4
|
+
|
|
5
|
+
WHICH model runs is not decided here. `mediaserving.resolve_media_model("image", ...)` answers
|
|
6
|
+
that from the file-09 registry, licence-gated and sized to THIS card's VRAM — so a 12 GB
|
|
7
|
+
contributor GPU runs the 10 GB model locally and free (plan 23 §B3) instead of failing to load
|
|
8
|
+
the 24 GB primary. This module used to hardcode `stabilityai/stable-diffusion-xl-base-1.0` as
|
|
9
|
+
`IMAGE_MODEL`'s default and say "Flux etc. can be swapped via env", which was an invitation to
|
|
10
|
+
put a non-commercial model on a GPU: FLUX.1 [dev] is exactly one of the five exclusions in plan
|
|
11
|
+
23 §A. The env override still exists and is now VALIDATED rather than trusted.
|
|
12
|
+
|
|
13
|
+
`AutoPipelineForText2Image`, not a model-specific class: the resolver may legitimately return
|
|
14
|
+
Qwen-Image, SDXL or a later primary, and each needs a different pipeline class. The Auto classes
|
|
15
|
+
read the repo's `model_index.json` and pick it, which is why plan 23 §B1 names `diffusers` as
|
|
16
|
+
the core serving path rather than one model's pipeline.
|
|
17
|
+
|
|
18
|
+
Defence in depth against NSFW (on top of the safety INPUT/OUTPUT gates in `safety.py`): a
|
|
19
|
+
strong default NEGATIVE prompt steers the model away from nudity/sexual content at
|
|
20
|
+
generation time. The authoritative blocks remain the gates — this just makes the generator
|
|
21
|
+
itself disinclined to produce it.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import io
|
|
26
|
+
|
|
27
|
+
from . import mediaserving
|
|
28
|
+
|
|
29
|
+
# Baked-in safety negative prompt — always applied, appended to any caller negative.
|
|
30
|
+
_SAFETY_NEGATIVE = "nsfw, nude, nudity, naked, sexual, explicit, pornographic, erotic, gore"
|
|
31
|
+
|
|
32
|
+
_pipe = None
|
|
33
|
+
_img2img = None
|
|
34
|
+
_loaded_ref = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def device_vram_gb() -> float:
|
|
38
|
+
"""Usable VRAM on this node's first GPU, 0.0 where there is none.
|
|
39
|
+
|
|
40
|
+
This is what makes §B3's local-first path real: the resolver needs to know what actually
|
|
41
|
+
fits here, or "local-first" means "load the primary and crash".
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
import torch # type: ignore
|
|
45
|
+
if not torch.cuda.is_available():
|
|
46
|
+
return 0.0
|
|
47
|
+
return torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
|
|
48
|
+
except Exception:
|
|
49
|
+
return 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def available() -> bool:
|
|
53
|
+
"""True iff this node can actually generate images (diffusers + torch + CUDA + a model
|
|
54
|
+
it is allowed to run). The last clause matters: a node with a working GPU and no
|
|
55
|
+
commercial-safe model that fits it cannot serve image jobs, and advertising the modality
|
|
56
|
+
would have the gateway route work it must then refuse."""
|
|
57
|
+
try:
|
|
58
|
+
import torch # type: ignore
|
|
59
|
+
import diffusers # type: ignore # noqa: F401
|
|
60
|
+
if not torch.cuda.is_available():
|
|
61
|
+
return False
|
|
62
|
+
except Exception:
|
|
63
|
+
return False
|
|
64
|
+
try:
|
|
65
|
+
resolved_model()
|
|
66
|
+
return True
|
|
67
|
+
except mediaserving.MediaUnavailable:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolved_model(*, node_region: str = "", user_region: str = ""):
|
|
72
|
+
"""The registry-declared, licence-gated image model this node will run, sized to its card.
|
|
73
|
+
|
|
74
|
+
Raises `mediaserving.MediaUnavailable` naming every candidate and why it was rejected.
|
|
75
|
+
"""
|
|
76
|
+
return mediaserving.resolve_media_model(
|
|
77
|
+
"image",
|
|
78
|
+
node_region=node_region, user_region=user_region,
|
|
79
|
+
override=mediaserving.model_override("IMAGE_MODEL"),
|
|
80
|
+
max_vram_gb=device_vram_gb(),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load(spec):
|
|
85
|
+
"""Load (once) the text-to-image pipeline for `spec`. Reloads if the resolver's answer
|
|
86
|
+
changed — an operator flipping IMAGE_MODEL must not keep getting the old weights."""
|
|
87
|
+
global _pipe, _img2img, _loaded_ref
|
|
88
|
+
if _pipe is None or _loaded_ref != spec.ref:
|
|
89
|
+
import torch # type: ignore
|
|
90
|
+
from diffusers import AutoPipelineForText2Image # type: ignore
|
|
91
|
+
_pipe = AutoPipelineForText2Image.from_pretrained(
|
|
92
|
+
spec.repo, revision=spec.revision, torch_dtype=torch.float16).to("cuda")
|
|
93
|
+
_img2img = None # the cached img2img shared the OLD pipeline's components
|
|
94
|
+
_loaded_ref = spec.ref
|
|
95
|
+
return _pipe
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _load_img2img(spec):
|
|
99
|
+
"""The img2img pipeline, sharing the text pipeline's already-loaded components.
|
|
100
|
+
|
|
101
|
+
`from_pipe` reuses the same UNet/VAE/text encoders rather than loading a second ~7 GB copy —
|
|
102
|
+
conditioning is the SAME model applied differently, not a different model.
|
|
103
|
+
"""
|
|
104
|
+
global _img2img
|
|
105
|
+
base = _load(spec)
|
|
106
|
+
if _img2img is None:
|
|
107
|
+
from diffusers import AutoPipelineForImage2Image # type: ignore
|
|
108
|
+
_img2img = AutoPipelineForImage2Image.from_pipe(base)
|
|
109
|
+
return _img2img
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def generate_image(prompt: str, *, steps: int = 30, width: int = 1024, height: int = 1024,
|
|
113
|
+
negative: str = "", init: bytes = b"", strength: float = 0.4,
|
|
114
|
+
node_region: str = "", user_region: str = "") -> bytes:
|
|
115
|
+
"""Generate a PNG (bytes) from a text prompt. Raises RuntimeError where no allowed model
|
|
116
|
+
can run here; the safety negative prompt is always applied.
|
|
117
|
+
|
|
118
|
+
ENHANCE RATHER THAN RE-ROLL (file 03-E2). When `init` carries the user's local sketch this
|
|
119
|
+
runs img2img at `strength` (0.4 by default, the plan's decision) instead of generating from
|
|
120
|
+
the prompt alone. That distinction is the whole paid feature: the user has already SEEN the
|
|
121
|
+
local sketch and chose to improve THAT image. Regenerating from the prompt would hand back a
|
|
122
|
+
different composition with a different subject placement — not an enhancement of the thing
|
|
123
|
+
they paid to enhance, just a second, unrelated picture.
|
|
124
|
+
"""
|
|
125
|
+
if not prompt or not prompt.strip():
|
|
126
|
+
raise ValueError("empty image prompt")
|
|
127
|
+
try:
|
|
128
|
+
import torch # type: ignore
|
|
129
|
+
import diffusers # type: ignore # noqa: F401
|
|
130
|
+
if not torch.cuda.is_available():
|
|
131
|
+
raise RuntimeError("image generation is not available on this node: no CUDA GPU")
|
|
132
|
+
except ImportError as e:
|
|
133
|
+
raise RuntimeError(f"image generation is not available on this node: {e}") from e
|
|
134
|
+
spec = resolved_model(node_region=node_region, user_region=user_region)
|
|
135
|
+
|
|
136
|
+
neg = f"{negative}, {_SAFETY_NEGATIVE}".strip(", ") if negative else _SAFETY_NEGATIVE
|
|
137
|
+
steps = max(1, min(steps, 60))
|
|
138
|
+
|
|
139
|
+
if init:
|
|
140
|
+
from PIL import Image # type: ignore
|
|
141
|
+
# Strength is clamped, not trusted: 0 would return the sketch untouched (the user paid for
|
|
142
|
+
# nothing) and 1 discards it entirely (a re-roll wearing the name "enhance").
|
|
143
|
+
s = min(0.95, max(0.05, float(strength)))
|
|
144
|
+
sketch = Image.open(io.BytesIO(init)).convert("RGB")
|
|
145
|
+
image = _load_img2img(spec)(prompt=prompt, negative_prompt=neg, image=sketch,
|
|
146
|
+
strength=s, num_inference_steps=steps).images[0]
|
|
147
|
+
else:
|
|
148
|
+
image = _load(spec)(prompt=prompt, negative_prompt=neg, num_inference_steps=steps,
|
|
149
|
+
width=width, height=height).images[0]
|
|
150
|
+
buf = io.BytesIO()
|
|
151
|
+
image.save(buf, format="PNG")
|
|
152
|
+
return buf.getvalue()
|