CF Workers AI backend: flux direct (backnforth contract) + local border-flood keyer
/api/gen prefers Cloudflare flux-1-schnell (free ~10k neurons/day) over the farm; /api/rmbg falls back to a Pillow border-flood white-keyer when no MB_TOKEN (flood from edges through near-white only — traps-ledger rule, white ON the garment survives). Creds in gitignored .env, lifted from ultra. Verified end-to-end: brown-corduroy-jacket flux'd, keyed, in the wardrobe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
58354d9b66
commit
6f32c08aa9
1
.gitignore
vendored
1
.gitignore
vendored
@ -4,3 +4,4 @@ out/
|
|||||||
jobs/
|
jobs/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.env
|
||||||
|
|||||||
66
server.py
66
server.py
@ -30,8 +30,18 @@ EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'),
|
|||||||
CACHE = os.path.join(ROOT, '.glbcache')
|
CACHE = os.path.join(ROOT, '.glbcache')
|
||||||
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
|
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
|
||||||
OPS = os.path.join(ROOT, 'blender_ops.py')
|
OPS = os.path.join(ROOT, 'blender_ops.py')
|
||||||
|
# .env beside server.py (gitignored) — CF Workers AI creds live here, lifted from
|
||||||
|
# backnforth/.env on ultra (the proven flux.mjs contract). Never printed, never committed.
|
||||||
|
_envp = os.path.join(ROOT, '.env')
|
||||||
|
if os.path.exists(_envp):
|
||||||
|
for _l in open(_envp):
|
||||||
|
if '=' in _l and not _l.startswith('#'):
|
||||||
|
k, v = _l.split('=', 1)
|
||||||
|
os.environ.setdefault(k.strip(), v.strip())
|
||||||
MB = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
MB = os.environ.get('MB_HOST', 'http://100.89.131.57:8777')
|
||||||
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
MB_TOKEN = os.environ.get('MB_TOKEN', '')
|
||||||
|
CF_ACCT = os.environ.get('CLOUDFLARE_ACCOUNT_ID', '')
|
||||||
|
CF_TOKEN = os.environ.get('CLOUDFLARE_API_TOKEN', '')
|
||||||
PORT = int(os.environ.get('WG_PORT', 8150))
|
PORT = int(os.environ.get('WG_PORT', 8150))
|
||||||
HOST = os.environ.get('WG_HOST', '127.0.0.1')
|
HOST = os.environ.get('WG_HOST', '127.0.0.1')
|
||||||
for d in list(DIRS.values()) + [CACHE]:
|
for d in list(DIRS.values()) + [CACHE]:
|
||||||
@ -182,7 +192,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
if u.path == '/':
|
if u.path == '/':
|
||||||
return self.send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html')
|
return self.send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html')
|
||||||
if u.path == '/api/lib':
|
if u.path == '/api/lib':
|
||||||
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'blender': os.path.exists(BLENDER)})
|
return self.j({'lib': scan(), 'mb': bool(MB_TOKEN), 'cf': bool(CF_ACCT and CF_TOKEN),
|
||||||
|
'blender': os.path.exists(BLENDER)})
|
||||||
if u.path.startswith('/api/job/'):
|
if u.path.startswith('/api/job/'):
|
||||||
jid = u.path.rsplit('/', 1)[1]
|
jid = u.path.rsplit('/', 1)[1]
|
||||||
return self.j(JOBS.get(jid) or {'status': 'unknown'})
|
return self.j(JOBS.get(jid) or {'status': 'unknown'})
|
||||||
@ -256,8 +267,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.j({'job': jid})
|
return self.j({'job': jid})
|
||||||
|
|
||||||
if u.path == '/api/gen': # the garment pipeline, step 1: flux image
|
if u.path == '/api/gen': # the garment pipeline, step 1: flux image
|
||||||
if not MB_TOKEN:
|
if not (CF_ACCT and CF_TOKEN) and not MB_TOKEN:
|
||||||
return self.j({'error': 'MB_TOKEN not set on this box — export MB_TOKEN=… and restart'}, 400)
|
return self.j({'error': 'no image backend — need .env CF creds or MB_TOKEN'}, 400)
|
||||||
prompt = body.get('prompt', '')
|
prompt = body.get('prompt', '')
|
||||||
style = ('product photo of a single {} laid flat on a plain white background, '
|
style = ('product photo of a single {} laid flat on a plain white background, '
|
||||||
'front view, no mannequin, no person, no text, soft even lighting, '
|
'front view, no mannequin, no person, no text, soft even lighting, '
|
||||||
@ -265,36 +276,73 @@ class H(BaseHTTPRequestHandler):
|
|||||||
jid = new_job('gen', 'flux: ' + prompt[:40])
|
jid = new_job('gen', 'flux: ' + prompt[:40])
|
||||||
|
|
||||||
def fx(jid):
|
def fx(jid):
|
||||||
|
tgt = os.path.join(DIRS['gen'], slug(prompt) + '.png')
|
||||||
|
if CF_ACCT and CF_TOKEN: # Cloudflare Workers AI direct — the flux.mjs contract
|
||||||
|
import base64
|
||||||
|
JOBS[jid]['note'] = 'cloudflare flux-1-schnell'
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f'https://api.cloudflare.com/client/v4/accounts/{CF_ACCT}/ai/run/@cf/black-forest-labs/flux-1-schnell',
|
||||||
|
data=json.dumps({'prompt': style, 'steps': 8, 'width': 768, 'height': 768}).encode(),
|
||||||
|
headers={'Authorization': 'Bearer ' + CF_TOKEN, 'Content-Type': 'application/json'})
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
out = json.load(r)
|
||||||
|
if not out.get('success'):
|
||||||
|
raise RuntimeError(str(out.get('errors'))[:300])
|
||||||
|
with open(tgt, 'wb') as f:
|
||||||
|
f.write(base64.b64decode(out['result']['image']))
|
||||||
|
return tgt
|
||||||
j = mb_req('/api/jobs', {'operator': 'flux_local', 'asset_id': None,
|
j = mb_req('/api/jobs', {'operator': 'flux_local', 'asset_id': None,
|
||||||
'params': {'prompt': style, 'model': 'flux2-klein-4b',
|
'params': {'prompt': style, 'model': 'flux2-klein-4b',
|
||||||
'steps': 4, 'width': 768, 'height': 768,
|
'steps': 4, 'width': 768, 'height': 768,
|
||||||
'seed': int(time.time()) % 99991}})
|
'seed': int(time.time()) % 99991}})
|
||||||
done = mb_wait(j.get('id') or j.get('job_id'), jid)
|
mb_wait(j.get('id') or j.get('job_id'), jid)
|
||||||
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
||||||
if not outs:
|
if not outs:
|
||||||
raise RuntimeError('no image came back')
|
raise RuntimeError('no image came back')
|
||||||
tgt = os.path.join(DIRS['gen'], slug(prompt) + '.png')
|
|
||||||
return mb_download(outs[0], tgt)
|
return mb_download(outs[0], tgt)
|
||||||
job_thread(jid, fx)
|
job_thread(jid, fx)
|
||||||
return self.j({'job': jid})
|
return self.j({'job': jid})
|
||||||
|
|
||||||
if u.path == '/api/rmbg': # step 2: cut it out
|
if u.path == '/api/rmbg': # step 2: cut it out (farm RMBG, else local white-keyer)
|
||||||
if not MB_TOKEN:
|
|
||||||
return self.j({'error': 'MB_TOKEN not set'}, 400)
|
|
||||||
p = body.get('path', '')
|
p = body.get('path', '')
|
||||||
if not allowed(p):
|
if not allowed(p):
|
||||||
return self.j({'error': 'nope'}, 403)
|
return self.j({'error': 'nope'}, 403)
|
||||||
jid = new_job('rmbg', 'cutout ' + os.path.basename(p))
|
jid = new_job('rmbg', 'cutout ' + os.path.basename(p))
|
||||||
|
|
||||||
def fx(jid):
|
def fx(jid):
|
||||||
|
tgt = p.rsplit('.', 1)[0] + '-cut.png'
|
||||||
|
if MB_TOKEN: # RMBG-2.0 on the farm — best quality
|
||||||
aid = mb_upload(p)
|
aid = mb_upload(p)
|
||||||
j = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': aid, 'params': {}})
|
j = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': aid, 'params': {}})
|
||||||
mb_wait(j.get('id') or j.get('job_id'), jid)
|
mb_wait(j.get('id') or j.get('job_id'), jid)
|
||||||
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
outs = mb_outputs(j.get('id') or j.get('job_id'))
|
||||||
if not outs:
|
if not outs:
|
||||||
raise RuntimeError('no cutout came back')
|
raise RuntimeError('no cutout came back')
|
||||||
tgt = p.rsplit('.', 1)[0] + '-cut.png'
|
|
||||||
return mb_download(outs[0], tgt)
|
return mb_download(outs[0], tgt)
|
||||||
|
# local fallback: border-flood white-keyer (never naive white→alpha — traps ledger).
|
||||||
|
# flood from the edges through near-white pixels only, so white ON the garment survives.
|
||||||
|
JOBS[jid]['note'] = 'local white-keyer'
|
||||||
|
from PIL import Image
|
||||||
|
im = Image.open(p).convert('RGBA')
|
||||||
|
w, h = im.size
|
||||||
|
px = im.load()
|
||||||
|
near = lambda c: c[0] > 232 and c[1] > 232 and c[2] > 232
|
||||||
|
seen = bytearray(w * h)
|
||||||
|
stack = [(x, y) for x in range(w) for y in (0, h - 1)] + \
|
||||||
|
[(x, y) for y in range(h) for x in (0, w - 1)]
|
||||||
|
while stack:
|
||||||
|
x, y = stack.pop()
|
||||||
|
i = y * w + x
|
||||||
|
if seen[i] or not near(px[x, y]):
|
||||||
|
continue
|
||||||
|
seen[i] = 1
|
||||||
|
px[x, y] = (0, 0, 0, 0)
|
||||||
|
if x > 0: stack.append((x - 1, y))
|
||||||
|
if x < w - 1: stack.append((x + 1, y))
|
||||||
|
if y > 0: stack.append((x, y - 1))
|
||||||
|
if y < h - 1: stack.append((x, y + 1))
|
||||||
|
im.save(tgt)
|
||||||
|
return tgt
|
||||||
job_thread(jid, fx)
|
job_thread(jid, fx)
|
||||||
return self.j({'job': jid})
|
return self.j({'job': jid})
|
||||||
|
|
||||||
|
|||||||
@ -228,7 +228,8 @@ $('clearAtBtn').onclick = () => { attached.forEach(a => a.parent && a.parent.rem
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
const r = await fetch('/api/lib').then(r => r.json());
|
const r = await fetch('/api/lib').then(r => r.json());
|
||||||
LIB = r.lib;
|
LIB = r.lib;
|
||||||
$('farm').textContent = (r.blender ? 'blender ✓' : 'blender ✗') + ' · farm ' + (r.mb ? '✓' : 'token missing');
|
$('farm').textContent = (r.blender ? 'blender ✓' : 'blender ✗') +
|
||||||
|
' · flux ' + (r.cf ? 'cf ✓' : r.mb ? 'farm ✓' : '✗') + ' · farm3D ' + (r.mb ? '✓' : 'token missing');
|
||||||
renderLists();
|
renderLists();
|
||||||
}
|
}
|
||||||
function rowHtml(it, kind) {
|
function rowHtml(it, kind) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user