#!/usr/bin/env python3 """ FLUXTRAIT Generation Script — runs ON the RTX PRO 6000 GPU box. Submits ComfyUI workflows via localhost:8188 API. Pipeline: FLUXTRAIT checkpoint → 3 skin LoRAs → PuLID @0.65 → KSampler → save + _meta.json Output: /root/fluxtrait/output/ """ import json, time, urllib.request, urllib.error, os, sys, traceback, glob COMFY = "http://localhost:8188" OUTPUT_DIR = "/home/ubuntu/fluxtrait/output" REFS_DIR = "/home/ubuntu/fluxtrait/refs" COMFYUI_INPUT = "/home/ubuntu/ComfyUI/input" # ComfyUI's LoadImage looks here # ============================================================ # CONFIG # ============================================================ CHECKPOINT = "FLUXTRAIT.safetensors" # fallback: flux1-dev.safetensors CLIP_L = "clip_l.safetensors" T5_XXL = "t5xxl_fp8_e4m3fn.safetensors" PULID_WEIGHT = 0.65 FLUX_GUIDANCE = 4.0 # match proven value from space_generate.py (was 3.5) STEPS = 24 WIDTH = 832 HEIGHT = 1216 LORA_STACK = [ {"name": "realistic_skin_texture.safetensors", "strength": 0.6}, {"name": "skin_no_plastic.safetensors", "strength": 0.6}, {"name": "detailed_perfection.safetensors", "strength": 0.4}, ] PROMPTS = { "studio_portrait": ( "skin texture style, realism, detailed. aidmarealisticskin. ultra detailed, " "detailed skin pore. RAW photo, portrait of a person, " "head and shoulders centered in frame, looking directly at camera, " "professional studio headshot photograph, soft Rembrandt lighting, " "neutral dark gray backdrop. Visible skin pores, natural skin texture " "with fine lines, minimal makeup, no foundation, natural imperfections, " "micro-texture, subsurface scattering. 85mm lens, f/2.8, shallow depth " "of field, professionally color graded, unretouched" ), "forest_mist": ( "skin texture style, realism, detailed. aidmarealisticskin. ultra detailed, " "detailed skin pore. RAW photo, portrait of a person " "standing in a misty ancient forest clearing, head and shoulders visible, " "centered in frame, facing the camera, dappled golden sunlight filtering " "through the canopy behind them, moss-covered stones in soft bokeh " "background. Visible skin pores, natural skin texture, minimal makeup. " "85mm portrait lens, shallow depth of field, natural lighting, unretouched" ), "candlelit_chamber": ( "skin texture style, realism, detailed. aidmarealisticskin. ultra detailed, " "detailed skin pore. RAW photo, portrait of a person " "in a dimly lit stone chamber, head and shoulders centered in frame, " "facing the camera, warm candlelight casting a golden glow on their face, " "rich deep shadows in the background, dramatic chiaroscuro lighting, " "medieval fantasy setting. Visible skin pores, natural skin texture. " "50mm lens, shallow depth of field, photorealistic, unretouched" ), "mountain_sunrise": ( "skin texture style, realism, detailed. aidmarealisticskin. ultra detailed, " "detailed skin pore. RAW photo, portrait of a person " "on a cliff overlooking a mountain range at sunrise, head and shoulders " "centered in frame, facing the camera, wind-swept hair, golden light " "bathing their face, dramatic clouds and mountain peaks behind them. " "Visible skin pores, natural skin texture, minimal makeup. 85mm portrait " "lens, shallow depth of field, golden hour lighting, unretouched" ), } # ============================================================ # WORKFLOW BUILDER # ============================================================ def make_workflow(prompt_text, ref_filename, seed): """Build FLUXTRAIT + LoRA stack + PuLID workflow JSON.""" wf = {} # Node 1: Checkpoint (model + CLIP — FLUXTRAIT has no VAE, use separate) wf["1"] = { "inputs": {"ckpt_name": CHECKPOINT}, "class_type": "CheckpointLoaderSimple", } # VAELoader — separate Flux VAE (FLUXTRAIT checkpoint doesn't include one) wf["vae1"] = { "inputs": {"vae_name": "ae.safetensors"}, "class_type": "VAELoader", } # Node 2: DualCLIPLoader (clip_l + t5xxl for Flux) wf["2"] = { "inputs": { "clip_name1": CLIP_L, "clip_name2": T5_XXL, "type": "flux", }, "class_type": "DualCLIPLoader", } # LoRA chain: checkpoint model (slot 0) + dual clip (slot 0) # LoraLoader outputs: slot 0=MODEL, slot 1=CLIP prev_model = "1" prev_clip = "2" prev_clip_slot = 0 # DualCLIPLoader outputs CLIP at slot 0 for i, lora in enumerate(LORA_STACK): node_id = f"lora{i}" wf[node_id] = { "inputs": { "lora_name": lora["name"], "strength_model": lora["strength"], "strength_clip": lora["strength"], "model": [prev_model, 0], "clip": [prev_clip, prev_clip_slot], }, "class_type": "LoraLoader", } prev_model = node_id # next LoRA reads MODEL from slot 0 prev_clip = node_id # next LoRA reads CLIP from slot 1 prev_clip_slot = 1 # LoraLoader CLIP output is slot 1 last_lora_node_model = f"lora{len(LORA_STACK) - 1}" if LORA_STACK else "1" last_lora_node_clip = f"lora{len(LORA_STACK) - 1}" if LORA_STACK else "2" last_lora_clip_slot = 1 if LORA_STACK else 0 # slot 1 from LoRA, slot 0 from DualCLIP # Positive + negative text encode wf["3"] = { "inputs": {"text": prompt_text, "clip": [last_lora_node_clip, last_lora_clip_slot]}, "class_type": "CLIPTextEncode", } wf["4"] = { "inputs": {"text": "", "clip": [last_lora_node_clip, last_lora_clip_slot]}, # empty neg for Flux "class_type": "CLIPTextEncode", } # Empty latent wf["5"] = { "inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1}, "class_type": "EmptyLatentImage", } # Flux guidance wf["8"] = { "inputs": {"conditioning": ["3", 0], "guidance": FLUX_GUIDANCE}, "class_type": "FluxGuidance", } # PuLID nodes wf["10"] = { "inputs": {"image": ref_filename}, "class_type": "LoadImage", } wf["11"] = { "inputs": {"pulid_file": "pulid_flux_v0.9.1.safetensors"}, "class_type": "PulidFluxModelLoader", } wf["12"] = { "inputs": {}, "class_type": "PulidFluxEvaClipLoader", } wf["13"] = { "inputs": {"provider": "CPU"}, "class_type": "PulidFluxInsightFaceLoader", } wf["14"] = { "inputs": { "model": [last_lora_node_model, 0], # from LoRA chain or checkpoint "pulid_flux": ["11", 0], "eva_clip": ["12", 0], "face_analysis": ["13", 0], "image": ["10", 0], "weight": PULID_WEIGHT, "start_at": 0.0, "end_at": 1.0, }, "class_type": "ApplyPulidFlux", } # KSampler — uses PuLID-patched model wf["6"] = { "inputs": { "seed": seed, "steps": STEPS, "cfg": 1.0, "sampler_name": "euler", "scheduler": "simple", "denoise": 1.0, "model": ["14", 0], "positive": ["8", 0], "negative": ["4", 0], "latent_image": ["5", 0], }, "class_type": "KSampler", } # VAE decode + save wf["7"] = { "inputs": {"samples": ["6", 0], "vae": ["vae1", 0]}, "class_type": "VAEDecode", } wf["9"] = { "inputs": {"filename_prefix": "fluxtrait", "images": ["7", 0]}, "class_type": "SaveImage", } return wf # ============================================================ # COMFYUI API # ============================================================ def upload_image(filepath): """Copy a reference image to ComfyUI's input directory. Uses direct file copy (more reliable than upload API, matches the proven pattern from space_generate.py).""" filename = os.path.basename(filepath) dest = os.path.join(COMFYUI_INPUT, filename) import shutil shutil.copy2(filepath, dest) return filename def submit_and_wait(prompt_text, ref_filename, seed, label): """Submit workflow and poll for result.""" workflow = make_workflow(prompt_text, ref_filename, seed) data = json.dumps({"prompt": workflow}).encode() req = urllib.request.Request( f"{COMFY}/prompt", data=data, headers={"Content-Type": "application/json"}, ) try: resp = urllib.request.urlopen(req, timeout=30) result = json.loads(resp.read()) except urllib.error.HTTPError as e: body = e.read().decode()[:500] return False, f"HTTP {e.code}: {body}" except Exception as e: return False, f"Submit error: {e}" if result.get("node_errors"): errs = json.dumps(result["node_errors"])[:500] return False, f"Node validation errors: {errs}" prompt_id = result.get("prompt_id", "") for i in range(180): # 6 min timeout per image time.sleep(2) try: hist_resp = urllib.request.urlopen(f"{COMFY}/history/{prompt_id}", timeout=10) hist = json.loads(hist_resp.read()) except: continue if prompt_id not in hist: continue pd = hist[prompt_id] status = pd.get("status", {}) status_str = status.get("status_str", "") if status_str == "error": msgs = status.get("messages", []) error_detail = "" for msg in msgs: if isinstance(msg, list) and len(msg) >= 2: if "execution_error" in str(msg[0]): error_detail = json.dumps(msg[1], indent=2)[:2000] return False, f"KSampler error: {error_detail or json.dumps(msgs)[:1000]}" outputs = pd.get("outputs", {}) for nid, nout in outputs.items(): if "images" in nout and nout["images"]: return True, nout["images"][0] if status_str and status_str != "success": return False, f"Unexpected status: {status_str}" return False, "Timeout (6 min)" def download_image(img_info): """Download generated image via ComfyUI /view endpoint.""" view_url = ( f"{COMFY}/view" f"?filename={img_info['filename']}" f"&subfolder={img_info.get('subfolder', '')}" f"&type={img_info.get('type', 'output')}" ) resp = urllib.request.urlopen(view_url, timeout=30) return resp.read() # ============================================================ # MAIN # ============================================================ def main(): import argparse parser = argparse.ArgumentParser(description="FLUXTRAIT generation") parser.add_argument("--test", action="store_true", help="Run single test image first. Stops after 1 success/failure.") parser.add_argument("--full", action="store_true", help="Skip test, run full batch (40 images).") args = parser.parse_args() # Default: if neither flag, run test first then ask test_mode = args.test or not args.full # Clean previous run import shutil if os.path.exists(OUTPUT_DIR): shutil.rmtree(OUTPUT_DIR) os.makedirs(OUTPUT_DIR, exist_ok=True) # Find reference images ref_files = sorted(glob.glob(os.path.join(REFS_DIR, "*.png"))) if not ref_files: print(f"❌ No reference images found in {REFS_DIR}") print(" Upload reference images before running this script.") sys.exit(1) print(f"Found {len(ref_files)} reference images") for f in ref_files: print(f" - {os.path.basename(f)}") # Upload references to ComfyUI input dir print("\nUploading reference images to ComfyUI...") ref_names = [] for f in ref_files: name = upload_image(f) ref_names.append((os.path.basename(f), name)) print(f" ✅ {os.path.basename(f)} → {name}") total = len(ref_names) * len(PROMPTS) done = 0 failed = 0 manifest = [] prompt_keys = list(PROMPTS.keys()) print(f"\n{'='*60}") print(f"FLUXTRAIT Generation: {total} images") print(f" {len(ref_names)} refs × {len(PROMPTS)} prompts") print(f" Checkpoint: {CHECKPOINT}") print(f" LoRAs: {', '.join(l['name'] for l in LORA_STACK)}") print(f" PuLID weight: {PULID_WEIGHT}") print(f" Guidance: {FLUX_GUIDANCE}, Steps: {STEPS}") print(f" Resolution: {WIDTH}×{HEIGHT}") print(f" Output: {OUTPUT_DIR}") if test_mode: print(f" ⚠️ TEST MODE: generating 1 image first, then stopping") print(f"{'='*60}\n") # --- TEST MODE: single image validation --- if test_mode: ref_orig, ref_comfy = ref_names[0] prompt_name = "studio_portrait" prompt_text = PROMPTS[prompt_name] seed = 42 label = "fluxtrait_TEST" print(f"[TEST] {label} (seed={seed})...", flush=True) print(f" Ref: {ref_orig}", flush=True) print(f" Prompt: {prompt_name}", flush=True) print(f" Checkpoint: {CHECKPOINT}", flush=True) print(f" LoRAs: {LORA_STACK}", flush=True) print(f" PuLID: {PULID_WEIGHT}", flush=True) success, result = submit_and_wait(prompt_text, ref_comfy, seed, label) if not success: print(f"\n❌ TEST FAILED: {result}", flush=True) print(f"\nThis error must be fixed before running the full batch.", flush=True) with open(os.path.join(OUTPUT_DIR, "TEST_FAILED.txt"), "w") as f: f.write(f"Error: {result}\n\n") f.write(f"Checkpoint: {CHECKPOINT}\n") f.write(f"LoRAs: {json.dumps(LORA_STACK)}\n") f.write(f"PuLID: {PULID_WEIGHT}\n") f.write(f"Ref: {ref_orig}\n") sys.exit(1) # Download and save test image img_data = download_image(result) out_path = os.path.join(OUTPUT_DIR, f"{label}.png") with open(out_path, "wb") as f: f.write(img_data) meta = { "positive_prompt": prompt_text, "negative_prompt": "", "seed": seed, "steps": STEPS, "cfg": 1.0, "width": WIDTH, "height": HEIGHT, "ip_strength": PULID_WEIGHT, "generator": "rtx6000_comfyui", "pulid_weight": PULID_WEIGHT, "reference_image": ref_orig, "prompt_type": prompt_name, "checkpoint": CHECKPOINT, "loras": [{"name": l["name"], "weight": l["strength"]} for l in LORA_STACK], } with open(os.path.join(OUTPUT_DIR, f"{label}_meta.json"), "w") as f: json.dump(meta, f, indent=2) sz_kb = len(img_data) // 1024 print(f"\n✅ TEST PASSED! Image: {sz_kb}KB", flush=True) print(f" Saved: {out_path}", flush=True) print(f" _meta.json written", flush=True) print(f"\n To run full batch: python3 generate.py --full", flush=True) sys.exit(0) # --- FULL BATCH MODE --- for ref_idx, (ref_orig, ref_comfy) in enumerate(ref_names): for prompt_idx, (prompt_name, prompt_text) in enumerate(PROMPTS.items()): seed = 10000 * ref_idx + prompt_idx * 1000 + 42 label = f"fluxtrait_ref{ref_idx}_{prompt_name}" print(f"[{done+1}/{total}] {label} (seed={seed})...", flush=True) success, result = submit_and_wait(prompt_text, ref_comfy, seed, label) if success: try: img_data = download_image(result) out_path = os.path.join(OUTPUT_DIR, f"{label}.png") with open(out_path, "wb") as f: f.write(img_data) # Write _meta.json sidecar for the gallery meta = { "positive_prompt": prompt_text, "negative_prompt": "", "seed": seed, "steps": STEPS, "cfg": 1.0, "width": WIDTH, "height": HEIGHT, "ip_strength": PULID_WEIGHT, "generator": "rtx6000_comfyui", "pulid_weight": PULID_WEIGHT, "reference_image": ref_orig, "prompt_type": prompt_name, "checkpoint": CHECKPOINT, "loras": [ {"name": l["name"], "weight": l["strength"]} for l in LORA_STACK ], "workflow_json": json.dumps(make_workflow(prompt_text, ref_comfy, seed)), } meta_path = os.path.join(OUTPUT_DIR, f"{label}_meta.json") with open(meta_path, "w") as f: json.dump(meta, f, indent=2) done += 1 elapsed_label = f"{len(img_data)//1024}KB" print(f" ✅ {elapsed_label} + meta → {out_path}", flush=True) manifest.append({"label": label, "ref": ref_orig, "prompt": prompt_name, "seed": seed, "size_kb": len(img_data)//1024, "status": "ok"}) except Exception as e: failed += 1 print(f" ❌ Download failed: {e}", flush=True) manifest.append({"label": label, "ref": ref_orig, "prompt": prompt_name, "seed": seed, "error": str(e)}) else: failed += 1 print(f" ❌ FAILED: {result}", flush=True) manifest.append({"label": label, "ref": ref_orig, "prompt": prompt_name, "seed": seed, "error": result}) # Stop on first KSampler error (usually model/patch issue) if "KSampler" in str(result) or "execution_error" in str(result): print(" ⚠️ KSampler error — stopping. Check model files and PuLID patch.") with open(os.path.join(OUTPUT_DIR, "manifest.json"), "w") as f: json.dump(manifest, f, indent=2) sys.exit(1) time.sleep(2) # small delay between gens # Write manifest with open(os.path.join(OUTPUT_DIR, "manifest.json"), "w") as f: json.dump(manifest, f, indent=2) # DONE marker for the poller with open(os.path.join(OUTPUT_DIR, "DONE"), "w") as f: f.write(f"{done} success, {failed} failed") print(f"\n{'='*60}") print(f"COMPLETE: {done} success, {failed} failed out of {total}") print(f"Output: {OUTPUT_DIR}") print(f"{'='*60}") if __name__ == "__main__": try: main() except Exception as e: print(f"\nFATAL ERROR: {e}", flush=True) traceback.print_exc() with open(os.path.join(OUTPUT_DIR, "FATAL_ERROR.txt"), "w") as f: f.write(f"{e}\n\n{traceback.format_exc()}") sys.exit(1)