diff --git a/.gitignore b/.gitignore index 857d141..4cfb519 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ out/ jobs/ __pycache__/ .DS_Store +.env diff --git a/server.py b/server.py index 09e8a27..dfe93a8 100644 --- a/server.py +++ b/server.py @@ -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}) diff --git a/web/index.html b/web/index.html index 1d696f0..ba3c8fc 100644 --- a/web/index.html +++ b/web/index.html @@ -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) {