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:
type-two 2026-07-21 00:16:37 +10:00
parent 58354d9b66
commit 6f32c08aa9
3 changed files with 66 additions and 16 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ out/
jobs/
__pycache__/
.DS_Store
.env

View File

@ -30,8 +30,18 @@ EXTRA_BODIES = [os.path.expanduser('~/Documents/anatomy'),
CACHE = os.path.join(ROOT, '.glbcache')
BLENDER = os.environ.get('WG_BLENDER', '/Applications/Blender.app/Contents/MacOS/Blender')
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_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))
HOST = os.environ.get('WG_HOST', '127.0.0.1')
for d in list(DIRS.values()) + [CACHE]:
@ -182,7 +192,8 @@ class H(BaseHTTPRequestHandler):
if u.path == '/':
return self.send(200, open(os.path.join(ROOT, 'web', 'index.html'), 'rb').read(), 'text/html')
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/'):
jid = u.path.rsplit('/', 1)[1]
return self.j(JOBS.get(jid) or {'status': 'unknown'})
@ -256,8 +267,8 @@ class H(BaseHTTPRequestHandler):
return self.j({'job': jid})
if u.path == '/api/gen': # the garment pipeline, step 1: flux image
if not MB_TOKEN:
return self.j({'error': 'MB_TOKEN not set on this box — export MB_TOKEN=… and restart'}, 400)
if not (CF_ACCT and CF_TOKEN) and not MB_TOKEN:
return self.j({'error': 'no image backend — need .env CF creds or MB_TOKEN'}, 400)
prompt = body.get('prompt', '')
style = ('product photo of a single {} laid flat on a plain white background, '
'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])
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,
'params': {'prompt': style, 'model': 'flux2-klein-4b',
'steps': 4, 'width': 768, 'height': 768,
'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'))
if not outs:
raise RuntimeError('no image came back')
tgt = os.path.join(DIRS['gen'], slug(prompt) + '.png')
return mb_download(outs[0], tgt)
job_thread(jid, fx)
return self.j({'job': jid})
if u.path == '/api/rmbg': # step 2: cut it out
if not MB_TOKEN:
return self.j({'error': 'MB_TOKEN not set'}, 400)
if u.path == '/api/rmbg': # step 2: cut it out (farm RMBG, else local white-keyer)
p = body.get('path', '')
if not allowed(p):
return self.j({'error': 'nope'}, 403)
jid = new_job('rmbg', 'cutout ' + os.path.basename(p))
def fx(jid):
aid = mb_upload(p)
j = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': aid, 'params': {}})
mb_wait(j.get('id') or j.get('job_id'), jid)
outs = mb_outputs(j.get('id') or j.get('job_id'))
if not outs:
raise RuntimeError('no cutout came back')
tgt = p.rsplit('.', 1)[0] + '-cut.png'
return mb_download(outs[0], tgt)
if MB_TOKEN: # RMBG-2.0 on the farm — best quality
aid = mb_upload(p)
j = mb_req('/api/jobs', {'operator': 'bg_remove_local', 'asset_id': aid, 'params': {}})
mb_wait(j.get('id') or j.get('job_id'), jid)
outs = mb_outputs(j.get('id') or j.get('job_id'))
if not outs:
raise RuntimeError('no cutout came back')
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)
return self.j({'job': jid})

View File

@ -228,7 +228,8 @@ $('clearAtBtn').onclick = () => { attached.forEach(a => a.parent && a.parent.rem
async function refresh() {
const r = await fetch('/api/lib').then(r => r.json());
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();
}
function rowHtml(it, kind) {