diff --git a/server.py b/server.py index b7433ef..78fd90e 100644 --- a/server.py +++ b/server.py @@ -108,17 +108,39 @@ def doll_catalogue(): return DOLL.catalogue() if DOLL else {} -# The prompt that produced the 441 shipped sprites, lifted verbatim from 90sDJsim's -# gen_wardrobe.py. Two hard-won rules are baked in and must not be "improved": -# · SOLID BRIGHT GREEN background — grey/checkerboard backgrounds eat garments in the key, -# and plaid survives green. This is why we don't generate on white for the doll tier. +# --------------------------------------------------------------------------------------- +# Prompt templates. `doll2d` is the one that actually shipped the 441 sprites; the green +# variant is 90sDJsim's later gen_wardrobe fallback. Rules baked in, do not "improve": # · no text/words/logos — flux can't spell, so band tees are ART, never readable words. -DOLL_PROMPT = ('1990s Australian {phrase}, product photo, flat lay, centered, ' - 'solid bright green background, no text, no words, no logos, no person, no hanger') -# The 3D tier wants a plain-white product shot instead — nothing gets keyed on green there. -FLAT_PROMPT = ('product photo of a single {phrase} laid flat on a plain white background, front view, ' +# · green is for PLAID and busy patterns. A bench run measured klein-4b on pure white +# keying cleanly (7/7 flood seeds) on both a dark solid and a busy multicolour knit, so +# white is the default and green is the escape hatch, not the other way round. +TEMPLATES = { + 'flat3d': ('product photo of a single {phrase} laid flat on a plain white background, front view, ' 'no mannequin, no person, no text, soft even lighting, 1990s Australian op-shop garment, ' - 'slightly worn') + 'slightly worn'), + 'doll2d': ('paper doll clothing item for a dress-up game, {phrase}, {year} australia, {view}, ' + 'floating centered on a plain pure white background, painterly stylized 3d game ' + 'illustration, full item visible, no body, no person, no mannequin, no hanger, ' + 'no coat hanger, no text, no logos'), + 'doll2d_green': ('1990s Australian {phrase}, product photo, flat lay, centered, ' + 'solid bright green background, no text, no words, no logos, no person, no hanger'), +} +VIEWS = {'hat': 'front view', 'top': 'front view laid flat with the sleeves down', + 'bottom': 'front view laid perfectly flat on the ground', + 'shoes': 'the pair side by side, front view', 'bag': 'front view'} +DOLL_PROMPT = TEMPLATES['doll2d_green'] # back-compat alias +FLAT_PROMPT = TEMPLATES['flat3d'] + + +def gen_seed(phrase, variant=0): + """Reproducible seed from the phrase — reroll by bumping `variant`. + + Replaces two broken schemes: /api/gen used `time.time() % 99991` (nothing was ever + reproducible) and /api/doll/gen inherited the operator default seed=42 (the same phrase + produced a byte-identical image forever, so there was no reroll at all). + """ + return int(hashlib.sha1(f'{phrase}_{variant}'.encode()).hexdigest()[:7], 16) def run_blender(args, jid=None, script=None): @@ -212,9 +234,18 @@ def mb_wait(job_id, jid, timeout=900): def mb_outputs(job_id): + """Output assets for one farm job. + + The farm IGNORES ?parent_job= — a bogus id still returns the whole asset table (measured: + 2,578 rows, 34 distinct parents). Taking rows[0] only ever worked because the table comes + back newest-first, so two jobs in flight would happily download each other's image. Always + filter client-side. job.asset_ids is [] even on success, so it's no help either. + """ assets = mb_req(f'/api/assets?parent_job={job_id}') rows = assets.get('assets', assets) if isinstance(assets, dict) else assets - return rows or [] + if isinstance(rows, dict): + rows = rows.get('items', []) + return [r for r in (rows or []) if isinstance(r, dict) and str(r.get('parent_job')) == str(job_id)] def mb_download(asset, tgt): @@ -390,7 +421,17 @@ class H(BaseHTTPRequestHandler): return self.j({'error': 'slot must be hat|top|bottom|shoes|bag'}, 400) if not phrase: return self.j({'error': 'need a garment phrase'}, 400) + # doll2d (painterly, pure white) is the template the 441 shipped sprites were made + # with; doll2d_green is the escape hatch for plaid and busy patterns where a white + # key gets confused. Default to what shipped. + tpl = body.get('template', 'doll2d') + if tpl not in TEMPLATES: + return self.j({'error': 'template must be one of ' + '|'.join(TEMPLATES)}, 400) + variant = int(body.get('variant', 0) or 0) + style = TEMPLATES[tpl].format(phrase=phrase, year=body.get('year', 1990), + view=VIEWS.get(slot, 'front view')) jid = new_job('doll', f'{slot}: {phrase[:40]}') + JOBS[jid]['prompt'] = style def fx(jid): stage_dir = os.path.join(DIRS['gen'], '_doll') @@ -398,10 +439,13 @@ class H(BaseHTTPRequestHandler): base = DOLL.slugify(keyname) raw = os.path.join(stage_dir, f'{slot}_{base}_flux.png') cut = os.path.join(stage_dir, f'{slot}_{base}_keyed.png') - JOBS[jid]['note'] = 'flux on green' + JOBS[jid]['note'] = f'flux ({tpl})' + # 704, not 1024: the doll canvas is 704 wide, so generating bigger only means + # downscaling before the key and softening the edges we depend on. j = mb_req('/api/jobs', {'operator': 'flux_local', - 'params': {'prompt': DOLL_PROMPT.format(phrase=phrase), - 'width': 1024, 'height': 1024}}) + 'params': {'prompt': style, 'model': 'flux2-klein-4b', + 'steps': 4, 'width': 704, 'height': 704, + 'seed': gen_seed(phrase, variant)}}) gid = j.get('id') or j.get('job_id') mb_wait(gid, jid) outs = mb_outputs(gid) @@ -511,20 +555,39 @@ class H(BaseHTTPRequestHandler): if u.path == '/api/gen': # the garment pipeline, step 1: flux image 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, ' - '1990s Australian op-shop garment, slightly worn').format(prompt) - jid = new_job('gen', 'flux: ' + prompt[:40]) + phrase = body.get('phrase') or body.get('prompt', '') + if not phrase: + return self.j({'error': 'need a garment phrase'}, 400) + tpl = body.get('template', 'flat3d') + if tpl not in TEMPLATES: + return self.j({'error': 'template must be one of ' + '|'.join(TEMPLATES)}, 400) + # klein-4b is the DEFAULT because it measurably renders fabric better: at matched + # prompt/seed it holds corduroy wale across the garment where CF flux-1-schnell + # flattens it to smooth canvas. CF stays available — it's ~2s and never queues + # behind the farm's serial GPU lane, which is worth real money on a one-at-a-time UI. + backend = body.get('backend', 'klein' if MB_TOKEN else 'cf') + variant = int(body.get('variant', 0) or 0) + seed = body.get('seed') + seed = gen_seed(phrase, variant) if seed in (None, '') else int(seed) + wide = int(body.get('width') or (704 if tpl.startswith('doll2d') else 1024)) + steps = int(body.get('steps') or 4) + style = TEMPLATES[tpl].format(phrase=phrase, year=body.get('year', 1990), + view=VIEWS.get(body.get('view', 'top'), 'front view')) + if body.get('lora') and backend != 'sd': + pass # flux_local has no lora param at all — silently ignoring beats pretending + jid = new_job('gen', f'{backend}: {phrase[:36]}') + JOBS[jid]['prompt'] = style + JOBS[jid]['seed'] = seed 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 + tgt = os.path.join(DIRS['gen'], f'{slug(phrase)}-v{variant}.png') + if backend == 'cf' and CF_ACCT and CF_TOKEN: import base64 - JOBS[jid]['note'] = 'cloudflare flux-1-schnell' + JOBS[jid]['note'] = 'cloudflare flux-1-schnell (softer fabric)' 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(), + data=json.dumps({'prompt': style, 'steps': max(steps, 8), + 'width': wide, 'height': wide, 'seed': seed}).encode(), headers={'Authorization': 'Bearer ' + CF_TOKEN, 'Content-Type': 'application/json'}) with urllib.request.urlopen(req, timeout=120) as r: out = json.load(r) @@ -533,17 +596,19 @@ class H(BaseHTTPRequestHandler): 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, + JOBS[jid]['note'] = 'flux_local klein-4b' + j = mb_req('/api/jobs', {'operator': 'flux_local', 'params': {'prompt': style, 'model': 'flux2-klein-4b', - 'steps': 4, 'width': 768, 'height': 768, - 'seed': int(time.time()) % 99991}}) - mb_wait(j.get('id') or j.get('job_id'), jid) - outs = mb_outputs(j.get('id') or j.get('job_id')) + 'steps': steps, 'width': wide, 'height': wide, + 'seed': seed}}) + gid = j.get('id') or j.get('job_id') + mb_wait(gid, jid) + outs = mb_outputs(gid) if not outs: raise RuntimeError('no image came back') return mb_download(outs[0], tgt) job_thread(jid, fx) - return self.j({'job': jid}) + return self.j({'job': jid, 'seed': seed, 'prompt': style}) if u.path == '/api/rmbg': # step 2: cut it out (farm RMBG, else local white-keyer) p = body.get('path', '') @@ -563,7 +628,10 @@ class H(BaseHTTPRequestHandler): 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' + # Measured limit: flat3d product shots come back with a studio-grey backdrop and a soft + # drop shadow (border 215-244, ZERO pixels ≥250), so this >232 gate only partially seeds + # and leaves grey fringing. RMBG-2.0 is semantic and doesn't care — warn loudly here. + JOBS[jid]['note'] = 'local white-keyer (no farm token — grey backdrops will fringe)' from PIL import Image im = Image.open(p).convert('RGBA') w, h = im.size diff --git a/web/index.html b/web/index.html index 9cd6953..f436b0e 100644 --- a/web/index.html +++ b/web/index.html @@ -170,8 +170,18 @@