topguy commited on
Commit
467c85e
·
1 Parent(s): db5af08

refactor: modularize codebase and reorganize utilities

Browse files

- Split monolithic app.py into modules/config.py, modules/integrations.py, modules/core_logic.py, and modules/ui_layout.py.
- Consolited ComfyUI utilities into a dedicated comfy/ directory.
- Updated imports to use the new package structure.
- Enhanced .gitignore to include __pycache__ and temporary character saves.
- Documented new architecture and safer port management in design.md.

.gitignore CHANGED
@@ -1,7 +1,21 @@
1
  venv/
2
- __pycache__/
3
- *.pyc
4
  .env
5
  .vscode/
6
  .idea/
 
 
 
 
 
 
 
 
 
 
 
7
  *.png
 
 
 
 
 
 
1
  venv/
 
 
2
  .env
3
  .vscode/
4
  .idea/
5
+
6
+ # Byte-compiled / optimized / DLL files
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+
11
+ # Temporary character saves
12
+ rpg_character_config.json
13
+ temp_character_*.json
14
+
15
+ # Generated images (if they should be ignored)
16
  *.png
17
+ !comfy_workflow_visual.png # Exception example if needed
18
+
19
+ # OS generated files
20
+ .DS_Store
21
+ Thumbs.db
app.py CHANGED
@@ -1,643 +1,5 @@
1
  import gradio as gr
2
- import yaml
3
- import random
4
- import os
5
- from dotenv import load_dotenv
6
- from google import genai
7
- from google.genai import types
8
- from PIL import Image
9
- import io
10
- import json
11
- import tempfile
12
- import traceback
13
- import requests
14
- import uuid
15
- import time
16
-
17
- # Load environment variables
18
- load_dotenv()
19
-
20
- # Setup Gemini
21
- api_key = os.getenv("GEMINI_API_KEY")
22
- client = None
23
- gemini_active = False
24
- if api_key:
25
- try:
26
- client = genai.Client(api_key=api_key)
27
- gemini_active = True
28
- except Exception as e:
29
- print(f"Error initializing Gemini: {e}")
30
-
31
- EXAMPLES_DIR = "examples"
32
- COMFY_HOST = os.getenv("COMFY_HOST", "127.0.0.1")
33
- COMFY_PORT = os.getenv("COMFY_PORT", "8188")
34
- COMFY_URL = f"http://{COMFY_HOST}:{COMFY_PORT}"
35
-
36
- COMFY_WORKFLOW_FILE = "comfy_rpg_char_gen.json"
37
- OLLAMA_HOST = os.getenv("OLLAMA_HOST", "127.0.0.1")
38
- OLLAMA_PORT = os.getenv("OLLAMA_PORT", "11434")
39
- OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
40
-
41
- def load_system_prompt(key="refinement"):
42
- """Loads a system prompt from prompts.yaml."""
43
- try:
44
- with open("prompts.yaml", "r") as f:
45
- prompts = yaml.safe_load(f)
46
- return prompts.get(key, {}).get("system_instructions", "")
47
- except Exception as e:
48
- print(f"Error loading system prompt: {e}")
49
- return ""
50
-
51
- def get_ollama_models():
52
- """Fetches available models from Ollama server and checks if it's running."""
53
- url = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/tags"
54
- try:
55
- response = requests.get(url, timeout=2)
56
- if response.status_code == 200:
57
- models = response.json().get("models", [])
58
- return [m["name"] for m in models]
59
- return []
60
- except Exception:
61
- return []
62
-
63
- # Load features from YAML
64
- def load_features():
65
- with open("features.yaml", "r") as f:
66
- return yaml.safe_load(f)
67
-
68
- features_data = load_features()
69
-
70
- # Define segments for prompt building
71
- # (Category, Subcategory, Template Key)
72
- FEATURE_SEQUENCE = [
73
- ('identity', 'race', 'race'),
74
- ('identity', 'class', 'class'),
75
- ('identity', 'gender', 'gender'),
76
- ('identity', 'age', 'age'),
77
- ('expression_pose', 'expression', 'expression'),
78
- ('expression_pose', 'pose', 'pose'),
79
- ('appearance', 'hair_color', 'hair_color'),
80
- ('appearance', 'hair_style', 'hair_style'),
81
- ('appearance', 'eye_color', 'eye_color'),
82
- ('appearance', 'build', 'build'),
83
- ('appearance', 'skin_tone', 'skin_tone'),
84
- ('appearance', 'distinguishing_feature', 'distinguishing_feature'),
85
- ('equipment', 'armor', 'armor'),
86
- ('equipment', 'weapon', 'weapon'),
87
- ('equipment', 'accessory', 'accessory'),
88
- ('equipment', 'accessory', 'accessory2'),
89
- ('equipment', 'material', 'material'),
90
- ('environment', 'background', 'background'),
91
- ('environment', 'lighting', 'lighting'),
92
- ('environment', 'atmosphere', 'atmosphere'),
93
- ('vfx_style', 'vfx', 'vfx'),
94
- ('vfx_style', 'style', 'style'),
95
- ('vfx_style', 'mood', 'mood'),
96
- ('vfx_style', 'camera', 'camera'),
97
- ('technical', 'aspect_ratio', 'aspect_ratio')
98
- ]
99
-
100
- # Section names for extra info
101
- SECTIONS = ['Identity', 'Appearance', 'Equipment', 'Environment', 'Style']
102
-
103
- def get_detail(category, subcategory, key):
104
- """Retrieves the detailed description for a given key in a category/subcategory."""
105
- return features_data.get(category, {}).get(subcategory, {}).get(key, key)
106
-
107
- def generate_prompt(*args):
108
- """
109
- Assembles the prompt based on dropdown selections and extra text info.
110
- """
111
- num_features = len(FEATURE_SEQUENCE)
112
- feature_keys = args[:num_features]
113
- extra_infos = args[num_features : num_features + len(SECTIONS)]
114
-
115
- template = features_data.get("templates", {}).get("default", "")
116
-
117
- context = {}
118
- for i, (cat, subcat, t_key) in enumerate(FEATURE_SEQUENCE):
119
- key = feature_keys[i]
120
- context[t_key] = get_detail(cat, subcat, key)
121
-
122
- # Handle multiple accessories dynamically
123
- acc_list = []
124
- # Identify accessory keys in context
125
- for i, (cat, subcat, t_key) in enumerate(FEATURE_SEQUENCE):
126
- if subcat == 'accessory' and feature_keys[i] != "None":
127
- acc_list.append(context[t_key])
128
-
129
- if acc_list:
130
- context['accessories'] = ", and has " + " as well as ".join(acc_list)
131
- else:
132
- context['accessories'] = ""
133
-
134
- # Inject extra info into the context
135
- if extra_infos[0]: # Identity
136
- context['age'] += f", {extra_infos[0]}"
137
- if extra_infos[1]: # Appearance
138
- context['distinguishing_feature'] += f", also {extra_infos[1]}"
139
- if extra_infos[2]: # Equipment
140
- if context['accessories']:
141
- context['accessories'] += f", further complemented by {extra_infos[2]}"
142
- else:
143
- context['accessories'] = f", complemented by {extra_infos[2]}"
144
- if extra_infos[3]: # Environment
145
- context['atmosphere'] += f", additionally {extra_infos[3]}"
146
- if extra_infos[4]: # Style
147
- context['camera'] += f", art style notes: {extra_infos[4]}"
148
-
149
- try:
150
- return template.format(**context)
151
- except Exception as e:
152
- return f"Error building prompt: {e}"
153
-
154
- def handle_regeneration(*args):
155
- """Randomizes checkboxes and returns new values for dropdowns."""
156
- num_features = len(FEATURE_SEQUENCE)
157
- current_values = list(args[:num_features])
158
- checkboxes = args[num_features : num_features*2]
159
-
160
- new_values = []
161
- for i, (is_random, (cat, subcat, t_key)) in enumerate(zip(checkboxes, FEATURE_SEQUENCE)):
162
- if is_random:
163
- choices = list(features_data[cat][subcat].keys())
164
- new_values.append(random.choice(choices))
165
- else:
166
- new_values.append(current_values[i])
167
-
168
- return new_values
169
-
170
- def save_character(*args):
171
- """Saves all current UI values to a JSON file."""
172
- num_features = len(FEATURE_SEQUENCE)
173
- feature_keys = args[:num_features]
174
- checkboxes = args[num_features : num_features*2]
175
- extra_infos = args[num_features*2 : num_features*2 + len(SECTIONS)]
176
-
177
- data = {
178
- "features": {FEATURE_SEQUENCE[i][2]: feature_keys[i] for i in range(num_features)},
179
- "randomization": {FEATURE_SEQUENCE[i][2]: checkboxes[i] for i in range(num_features)},
180
- "extra_info": {SECTIONS[i].lower(): extra_infos[i] for i in range(len(SECTIONS))}
181
- }
182
-
183
- # Save to a file with a friendly name in a temp directory
184
- temp_dir = tempfile.mkdtemp()
185
- path = os.path.join(temp_dir, "rpg_character_config.json")
186
- with open(path, 'w') as f:
187
- json.dump(data, f, indent=4)
188
- return path
189
-
190
- def load_character(file):
191
- """Loads UI values from a JSON file."""
192
- if file is None:
193
- return [gr.update()] * (len(FEATURE_SEQUENCE) * 2 + len(SECTIONS))
194
-
195
- try:
196
- with open(file.name, 'r') as f:
197
- data = json.load(f)
198
-
199
- updates = []
200
- # Update dropdowns
201
- for _, _, t_key in FEATURE_SEQUENCE:
202
- updates.append(data.get("features", {}).get(t_key, gr.update()))
203
-
204
- # Update checkboxes (randomization flags)
205
- for _, _, t_key in FEATURE_SEQUENCE:
206
- updates.append(data.get("randomization", {}).get(t_key, gr.update()))
207
-
208
- # Update extra info textboxes
209
- for section in SECTIONS:
210
- updates.append(data.get("extra_info", {}).get(section.lower(), ""))
211
-
212
- return updates
213
- except Exception as e:
214
- print(f"Error loading character: {e}")
215
- return [gr.update()] * (len(FEATURE_SEQUENCE) * 2 + len(SECTIONS))
216
-
217
- def get_example_list():
218
- """Returns a list of JSON filenames in the examples directory."""
219
- if not os.path.exists(EXAMPLES_DIR):
220
- return []
221
- return [f for f in os.listdir(EXAMPLES_DIR) if f.endswith('.json')]
222
-
223
- def load_example_character(filename):
224
- """Loads a character config from the examples directory."""
225
- if not filename:
226
- return [gr.update()] * (len(FEATURE_SEQUENCE) * 2 + len(SECTIONS))
227
-
228
- path = os.path.join(EXAMPLES_DIR, filename)
229
- # Wrap path in a mock file object with a .name attribute for load_character
230
- class MockFile:
231
- def __init__(self, name):
232
- self.name = name
233
-
234
- return load_character(MockFile(path))
235
-
236
- def refine_with_gemini(prompt):
237
- if not gemini_active:
238
- return "Gemini API key not found in .env file."
239
-
240
- system_prompt = load_system_prompt("refinement")
241
- if not system_prompt:
242
- # Fallback if file is missing
243
- system_prompt = (
244
- "You are an expert prompt engineer for AI image generators. "
245
- "Your task is to take the provided technical prompt and refine it into a more vivid, "
246
- "artistic, and detailed description while maintaining all the core features."
247
- )
248
-
249
- try:
250
- response = client.models.generate_content(
251
- model='gemini-3-pro-preview',
252
- contents=f"{system_prompt}\n\nOriginal Prompt: {prompt}"
253
- )
254
- text = response.text.strip()
255
- # Remove common markdown code block wrappings if present
256
- if text.startswith("```"):
257
- lines = text.splitlines()
258
- if lines[0].startswith("```"):
259
- lines = lines[1:]
260
- if lines and lines[-1].startswith("```"):
261
- lines = lines[:-1]
262
- text = "\n".join(lines).strip()
263
- return text
264
- except Exception as e:
265
- return f"Error refining prompt with Gemini: {e}"
266
-
267
- def refine_with_ollama(prompt, model):
268
- """Refines the prompt using a local Ollama instance."""
269
- system_prompt = load_system_prompt("refinement")
270
- url = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/generate"
271
-
272
- payload = {
273
- "model": model,
274
- "prompt": f"{system_prompt}\n\nOriginal Prompt: {prompt}",
275
- "stream": False
276
- }
277
-
278
- try:
279
- response = requests.post(url, json=payload)
280
- response.raise_for_status()
281
- text = response.json().get("response", "").strip()
282
- # Clean up potential markdown
283
- if text.startswith("```"):
284
- lines = text.splitlines()
285
- if lines[0].startswith("```"): lines = lines[1:]
286
- if lines and lines[-1].startswith("```"): lines = lines[:-1]
287
- text = "\n".join(lines).strip()
288
- return text
289
- except Exception as e:
290
- return f"Error refining prompt with Ollama: {e}"
291
-
292
- def refine_master(prompt, backend, ollama_model):
293
- """Routes prompt refinement to the selected backend."""
294
- if not prompt.strip():
295
- return ""
296
- if backend == "Ollama (Local)":
297
- return refine_with_ollama(prompt, ollama_model)
298
- else:
299
- return refine_with_gemini(prompt)
300
-
301
- def generate_image_with_gemini(refined_prompt, technical_prompt, aspect_ratio):
302
- if not gemini_active:
303
- return None, None, "Gemini API key not found in .env file."
304
-
305
- # Priority: Refined Prompt -> Technical Prompt
306
- final_prompt = refined_prompt.strip() if refined_prompt and refined_prompt.strip() else technical_prompt.strip()
307
-
308
- if not final_prompt:
309
- return None, None, "No prompt available for generation."
310
-
311
- try:
312
- # Using the new SDK's generate_images method
313
- response = client.models.generate_images(
314
- model='imagen-4.0-generate-001',
315
- prompt=final_prompt,
316
- config=types.GenerateImagesConfig(
317
- aspect_ratio=aspect_ratio,
318
- output_mime_type='image/png'
319
- )
320
- )
321
- if response.generated_images:
322
- img = Image.open(io.BytesIO(response.generated_images[0].image.image_bytes))
323
- # Save as PNG to a temp file for download
324
- temp_dir = tempfile.mkdtemp()
325
- img_path = os.path.join(temp_dir, "rpg_portrait.png")
326
- img.save(img_path, "PNG")
327
- return img, img_path, f"Image generated using {'refined' if refined_prompt.strip() else 'technical'} prompt!"
328
- return None, None, "Gemini Image generation did not return any images."
329
- except Exception as e:
330
- traceback.print_exc()
331
- return None, None, f"Image Generation Error: {e}"
332
-
333
- def generate_image_with_comfy(prompt, aspect_ratio):
334
- """Generates an image using a local ComfyUI instance."""
335
- if not os.path.exists(COMFY_WORKFLOW_FILE):
336
- return None, None, f"Error: Worklfow file {COMFY_WORKFLOW_FILE} not found."
337
-
338
- try:
339
- with open(COMFY_WORKFLOW_FILE, 'r') as f:
340
- workflow = json.load(f)
341
-
342
- # Node Mapping based on comfy_rpg_char_gen.json
343
- # Node 6: Positive Prompt (CLIPTextEncode)
344
- # Node 7: Negative Prompt (CLIPTextEncode) - we keep original for now
345
- # Node 13: Aspect Ratio (EmptySD3LatentImage)
346
- # Node 38: Seed (KSampler)
347
-
348
- # 1. Update Prompt
349
- workflow["6"]["inputs"]["text"] = prompt
350
-
351
- # 2. Update Resolution based on Aspect Ratio
352
- res_map = {
353
- "1:1": (1024, 1024),
354
- "16:9": (1344, 768),
355
- "9:16": (768, 1344),
356
- "4:3": (1152, 864),
357
- "3:4": (864, 1152)
358
- }
359
- width, height = res_map.get(aspect_ratio, (1024, 1024))
360
- workflow["13"]["inputs"]["width"] = width
361
- workflow["13"]["inputs"]["height"] = height
362
-
363
- # 3. Randomize Seed
364
- workflow["38"]["inputs"]["seed"] = random.randint(1, 1125899906842624)
365
-
366
- # 4. Queue Prompt
367
- client_id = str(uuid.uuid4())
368
- payload = {"prompt": workflow, "client_id": client_id}
369
-
370
- print(f"Queueing ComfyUI prompt (client_id: {client_id})")
371
- response = requests.post(f"{COMFY_URL}/prompt", json=payload)
372
- response.raise_for_status()
373
- prompt_id = response.json().get("prompt_id")
374
-
375
- # 5. Poll for completion (simple approach)
376
- print(f"Waiting for prompt {prompt_id} to complete...")
377
- max_retries = 60
378
- for _ in range(max_retries):
379
- # Check history
380
- hist_resp = requests.get(f"{COMFY_URL}/history/{prompt_id}")
381
- if hist_resp.status_code == 200:
382
- history = hist_resp.json()
383
- if prompt_id in history:
384
- # Completed!
385
- outputs = history[prompt_id].get("outputs", {})
386
- # Find the first image in any output node
387
- for node_id in outputs:
388
- if "images" in outputs[node_id]:
389
- image_data = outputs[node_id]["images"][0]
390
- filename = image_data["filename"]
391
- subfolder = image_data["subfolder"]
392
- folder_type = image_data["type"]
393
-
394
- # Download the image
395
- img_url = f"{COMFY_URL}/view?filename={filename}&subfolder={subfolder}&type={folder_type}"
396
- img_resp = requests.get(img_url)
397
- img_resp.raise_for_status()
398
-
399
- img = Image.open(io.BytesIO(img_resp.content))
400
- # Save to temp
401
- temp_dir = tempfile.mkdtemp()
402
- img_path = os.path.join(temp_dir, "rpg_portrait_comfy.png")
403
- img.save(img_path, "PNG")
404
-
405
- return img, img_path, f"Image generated via ComfyUI (Node {node_id})!"
406
- time.sleep(1)
407
-
408
- return None, None, "ComfyUI generation timed out."
409
-
410
- except Exception as e:
411
- traceback.print_exc()
412
- return None, None, f"ComfyUI Error: {e}"
413
-
414
- def generate_image_master(refined_prompt, technical_prompt, aspect_ratio, backend):
415
- """Routes image generation to the selected backend."""
416
- # Priority: Refined Prompt -> Technical Prompt
417
- final_prompt = refined_prompt.strip() if refined_prompt and refined_prompt.strip() else technical_prompt.strip()
418
-
419
- if not final_prompt:
420
- return None, None, "No prompt available for generation."
421
-
422
- if backend == "ComfyUI (Local)":
423
- return generate_image_with_comfy(final_prompt, aspect_ratio)
424
- else:
425
- return generate_image_with_gemini(refined_prompt, technical_prompt, aspect_ratio)
426
-
427
- def build_ui():
428
- with gr.Blocks(title="RPGPortrait Prompt Builder Pro") as demo:
429
- gr.Markdown("# 🎨 RPGPortrait Pro")
430
- gr.Markdown("Create professional AI prompts and generate portraits.")
431
-
432
- dropdowns = []
433
- checkboxes = []
434
- extra_texts = []
435
-
436
- def create_feature_ui(category, subcategory, label, default_value):
437
- choices = list(features_data[category][subcategory].keys())
438
- gr.Markdown(f"**{label}**")
439
- with gr.Row(equal_height=True):
440
- dd = gr.Dropdown(choices=choices, value=default_value, scale=12, show_label=False)
441
- cb = gr.Checkbox(label="🎲", value=False, scale=1, min_width=50, container=False)
442
- dropdowns.append(dd)
443
- checkboxes.append(cb)
444
-
445
- with gr.Row():
446
- with gr.Column(scale=2):
447
- with gr.Tabs():
448
- with gr.TabItem("👤 Identity & Expression"):
449
- gr.Markdown("### 👤 Identity")
450
- create_feature_ui('identity', 'race', "Race", "Human")
451
- create_feature_ui('identity', 'class', "Class", "Fighter")
452
- create_feature_ui('identity', 'gender', "Gender", "Male")
453
- create_feature_ui('identity', 'age', "Age", "Young Adult")
454
- extra_id = gr.Textbox(placeholder="Extra Identity details (e.g. lineage, title)", label="Additional Identity Info")
455
- extra_texts.append(extra_id)
456
-
457
- gr.Markdown("### 🎭 Expression & Pose")
458
- create_feature_ui('expression_pose', 'expression', "Expression", "Determined")
459
- create_feature_ui('expression_pose', 'pose', "Pose", "Standing")
460
-
461
- with gr.TabItem("🎨 Appearance"):
462
- gr.Markdown("### 🎨 Appearance")
463
- create_feature_ui('appearance', 'hair_color', "Hair Color", "Brown")
464
- create_feature_ui('appearance', 'hair_style', "Hair Style", "Short")
465
- create_feature_ui('appearance', 'eye_color', "Eye Color", "Brown")
466
- create_feature_ui('appearance', 'build', "Build", "Average")
467
- create_feature_ui('appearance', 'skin_tone', "Skin Tone", "Fair")
468
- create_feature_ui('appearance', 'distinguishing_feature', "Distinguishing Feature", "None")
469
- extra_app = gr.Textbox(placeholder="Extra Appearance details (e.g. tattoos, scars)", label="Additional Appearance Info")
470
- extra_texts.append(extra_app)
471
-
472
- with gr.TabItem("⚔️ Equipment"):
473
- gr.Markdown("### ⚔️ Equipment")
474
- create_feature_ui('equipment', 'armor', "Armor/Clothing", "Travelers Clothes")
475
- create_feature_ui('equipment', 'weapon', "Primary Weapon", "Longsword")
476
- create_feature_ui('equipment', 'accessory', "Accessory 1", "None")
477
- create_feature_ui('equipment', 'accessory', "Accessory 2", "None")
478
- create_feature_ui('equipment', 'material', "Material Detail", "Weathered")
479
- extra_eq = gr.Textbox(placeholder="Extra Equipment details (e.g. weapon enchantments)", label="Additional Equipment Info")
480
- extra_texts.append(extra_eq)
481
-
482
- with gr.TabItem("🌍 Environment"):
483
- gr.Markdown("### 🌍 Environment")
484
- create_feature_ui('environment', 'background', "Background", "Forest")
485
- create_feature_ui('environment', 'lighting', "Lighting", "Natural Sunlight")
486
- create_feature_ui('environment', 'atmosphere', "Atmosphere", "Clear")
487
- extra_env = gr.Textbox(placeholder="Extra Environment details (e.g. time of day, weather)", label="Additional Environment Info")
488
- extra_texts.append(extra_env)
489
-
490
- with gr.TabItem("🪄 Style & Technical"):
491
- gr.Markdown("### 🪄 Style & Effects")
492
- create_feature_ui('vfx_style', 'vfx', "Special Effects", "None")
493
- create_feature_ui('vfx_style', 'style', "Art Style", "Digital Illustration")
494
- create_feature_ui('vfx_style', 'mood', "Mood", "Heroic")
495
- create_feature_ui('vfx_style', 'camera', "Camera Angle", "Bust")
496
- extra_sty = gr.Textbox(placeholder="Extra Style details (e.g. specific artists, colors)", label="Additional Style Info")
497
- extra_texts.append(extra_sty)
498
-
499
- gr.Markdown("### ⚙️ Technical")
500
- create_feature_ui('technical', 'aspect_ratio', "Aspect Ratio", "1:1")
501
-
502
- gr.Markdown("---")
503
-
504
- with gr.Row():
505
- example_dropdown = gr.Dropdown(choices=get_example_list(), label="Example Characters", scale=3)
506
- load_example_btn = gr.Button("📂 Load Example", variant="secondary", scale=1)
507
- save_btn = gr.Button("💾 Save Character", variant="secondary", scale=1)
508
- load_btn = gr.UploadButton("📂 Load Character", file_types=[".json"], variant="secondary", scale=1)
509
-
510
- with gr.Group():
511
- gr.Markdown("### ⚙️ Settings & Generation")
512
- with gr.Row():
513
- ollama_models = get_ollama_models()
514
- ollama_active = len(ollama_models) > 0
515
-
516
- refinement_backend = gr.Radio(
517
- choices=["Gemini (Cloud)", "Ollama (Local)"] if ollama_active else ["Gemini (Cloud)"],
518
- value="Gemini (Cloud)",
519
- label="Prompt Refinement Backend",
520
- scale=2
521
- )
522
-
523
- ollama_model_dropdown = gr.Dropdown(
524
- choices=ollama_models,
525
- value=ollama_models[0] if ollama_active else None,
526
- label="Ollama Model",
527
- visible=ollama_active and False,
528
- scale=1
529
- )
530
-
531
- with gr.Row():
532
- backend_selector = gr.Radio(
533
- choices=["Gemini (Cloud)", "ComfyUI (Local)"],
534
- value="Gemini (Cloud)",
535
- label="Image Generation Backend",
536
- scale=2
537
- )
538
- with gr.Column(scale=1):
539
- refine_btn = gr.Button("🧠 Refine Prompt", variant="primary")
540
- gen_img_btn = gr.Button("🖼️ Generate Image", variant="primary")
541
-
542
- with gr.Column(scale=1):
543
- gr.Markdown("### 📝 Prompts & Output")
544
- prompt_output = gr.Textbox(label="Generated Technical Prompt", lines=4, interactive=False)
545
- regenerate_btn = gr.Button("✨ Randomize Features", variant="secondary")
546
- refined_output = gr.Textbox(label="Refined Artistic Prompt", lines=6, interactive=True)
547
-
548
- gr.Markdown("---")
549
- image_output = gr.Image(label="Portrait", show_label=False)
550
- download_img_btn = gr.DownloadButton("📥 Download Portrait (PNG)", variant="secondary", visible=False)
551
- status_msg = gr.Markdown("")
552
- download_file = gr.File(label="Saved Character JSON", visible=False)
553
-
554
- all_input_components = dropdowns + extra_texts
555
-
556
- # Automatic prompt update on any input change
557
- for comp in all_input_components:
558
- comp.change(fn=generate_prompt, inputs=all_input_components, outputs=prompt_output)
559
-
560
- # Regenerate button logic
561
- regenerate_btn.click(
562
- fn=handle_regeneration,
563
- inputs=dropdowns + checkboxes,
564
- outputs=dropdowns
565
- ).then(
566
- # Trigger prompt update after dropdowns change
567
- fn=generate_prompt,
568
- inputs=all_input_components,
569
- outputs=prompt_output
570
- ).then(
571
- # Clear refined output since it's now stale
572
- fn=lambda: "",
573
- outputs=refined_output
574
- )
575
-
576
- # Prompt Refinement Logic
577
- refine_btn.click(
578
- fn=refine_master,
579
- inputs=[prompt_output, refinement_backend, ollama_model_dropdown],
580
- outputs=refined_output
581
- )
582
-
583
- # Image Generation Logic
584
- gen_img_btn.click(
585
- fn=generate_image_master,
586
- inputs=[refined_output, prompt_output, dropdowns[-1], backend_selector],
587
- outputs=[image_output, download_img_btn, status_msg]
588
- ).then(
589
- fn=lambda x: gr.update(value=x, visible=True) if x else gr.update(visible=False),
590
- inputs=download_img_btn,
591
- outputs=download_img_btn
592
- )
593
-
594
- # Save/Load Logic
595
-
596
- # Simple toggle for Ollama model visibility
597
- def toggle_ollama_visibility(backend):
598
- return gr.update(visible=(backend == "Ollama (Local)"))
599
-
600
- refinement_backend.change(
601
- fn=toggle_ollama_visibility,
602
- inputs=refinement_backend,
603
- outputs=ollama_model_dropdown
604
- )
605
-
606
- save_btn.click(
607
- fn=save_character,
608
- inputs=dropdowns + checkboxes + extra_texts,
609
- outputs=download_file
610
- ).then(
611
- fn=lambda: gr.update(visible=True),
612
- outputs=download_file
613
- )
614
-
615
- load_btn.upload(
616
- fn=load_character,
617
- inputs=load_btn,
618
- outputs=dropdowns + checkboxes + extra_texts
619
- ).then(
620
- # Trigger prompt update after loading
621
- fn=generate_prompt,
622
- inputs=all_input_components,
623
- outputs=prompt_output
624
- )
625
-
626
- load_example_btn.click(
627
- fn=load_example_character,
628
- inputs=example_dropdown,
629
- outputs=dropdowns + checkboxes + extra_texts
630
- ).then(
631
- # Trigger prompt update after loading
632
- fn=generate_prompt,
633
- inputs=all_input_components,
634
- outputs=prompt_output
635
- )
636
-
637
- # Initialize
638
- demo.load(fn=generate_prompt, inputs=all_input_components, outputs=prompt_output)
639
-
640
- return demo
641
 
642
  if __name__ == "__main__":
643
  demo = build_ui()
 
1
  import gradio as gr
2
+ from modules.ui_layout import build_ui
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  if __name__ == "__main__":
5
  demo = build_ui()
comfy_rpg_char_gen.json → comfy/comfy_rpg_char_gen.json RENAMED
File without changes
comfy_test.py → comfy/comfy_test.py RENAMED
File without changes
comfy_workflow_default.json → comfy/comfy_workflow_default.json RENAMED
File without changes
fetch_comfy_info.py → comfy/fetch_comfy_info.py RENAMED
File without changes
commit_msg.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ refactor: modularize codebase and reorganize utilities
2
+
3
+ - Split monolithic app.py into modules/config.py, modules/integrations.py, modules/core_logic.py, and modules/ui_layout.py.
4
+ - Consolited ComfyUI utilities into a dedicated comfy/ directory.
5
+ - Updated imports to use the new package structure.
6
+ - Enhanced .gitignore to include __pycache__ and temporary character saves.
7
+ - Documented new architecture and safer port management in design.md.
design.md CHANGED
@@ -52,13 +52,20 @@ The app uses a 3-stage prompt pipeline:
52
  ## Maintenance & Development Lessons
53
 
54
  ### 1. Server Lifecycle
55
- - **Mandatory Restarts**: Any modification to `app.py` or configuration files (`features.yaml`, `prompts.yaml`) requires a manual restart of the Gradio server to take effect. The UI does not hot-reload backend logic or YAML data.
56
- - **Process Management (CRITICAL)**: Always ensure the previously running process is fully terminated before starting a new one. Gradio will automatically increment the port (7860 -> 7861 -> 7862) if the old process is still bound to the port.
57
- - **Command**: Use `Get-Process -Name python | Stop-Process -Force` in PowerShell to clear all existing instances before a fresh run.
 
 
 
58
 
59
- ### 2. UI Verification
60
- - **Data Dependency**: When testing UI components or dropdowns via browser automation, verify that the target values exist in `features.yaml` first. Testing with invalid data (e.g., "Silver" hair color before it was added) leads to verification failures.
61
- - **UI State Reset**: Always refresh the browser page after a server restart to ensure the frontend state is synchronized with the new backend.
 
 
 
 
62
 
63
  ### 3. Documentation & Artifacts
64
  - **Path Formatting**: For markdown artifacts (like `walkthrough.md`), use absolute paths for media embeds. On Windows, prefix with a leading slash (e.g., `/C:/Users/...`) to comply with internal linting and ensuring reliable rendering.
 
52
  ## Maintenance & Development Lessons
53
 
54
  ### 1. Server Lifecycle
55
+ - **Mandatory Restarts**: Any modification to the `modules/` folder or configuration files (`features.yaml`, `prompts.yaml`) requires a manual restart of the Gradio server.
56
+ - **Process Management (CRITICAL)**: Always ensure the previously running process is fully terminated before starting a new one.
57
+ - **Safe Command**: Use this PowerShell command to only kill the process using the target port (7860):
58
+ ```powershell
59
+ Stop-Process -Id (Get-NetTCPConnection -LocalPort 7860 -ErrorAction SilentlyContinue).OwningProcess -Force -ErrorAction SilentlyContinue
60
+ ```
61
 
62
+ ### 2. Project Architecture (Modular)
63
+ - **`app.py`**: Entry point that launches the Gradio `demo`.
64
+ - **`modules/config.py`**: Global constants and environment variables.
65
+ - **`modules/integrations.py`**: Wrappers for AI backends (Gemini, Ollama, ComfyUI).
66
+ - **`modules/core_logic.py`**: Character state management and prompt assembly.
67
+ - **`modules/ui_layout.py`**: The full Gradio UI definition (`build_ui`).
68
+ - **`comfy/`**: Dedicated folder for ComfyUI-specific JSON workflows and utility scripts.
69
 
70
  ### 3. Documentation & Artifacts
71
  - **Path Formatting**: For markdown artifacts (like `walkthrough.md`), use absolute paths for media embeds. On Windows, prefix with a leading slash (e.g., `/C:/Users/...`) to comply with internal linting and ensuring reliable rendering.
modules/__init__.py ADDED
File without changes
modules/config.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from dotenv import load_dotenv
3
+
4
+ # Load environment variables
5
+ load_dotenv()
6
+
7
+ # ComfyUI Settings
8
+ COMFY_HOST = os.getenv("COMFY_HOST", "127.0.0.1")
9
+ COMFY_PORT = os.getenv("COMFY_PORT", "8188")
10
+ COMFY_URL = f"http://{COMFY_HOST}:{COMFY_PORT}"
11
+ COMFY_WORKFLOW_FILE = "comfy/comfy_rpg_char_gen.json"
12
+
13
+ # Ollama Settings
14
+ OLLAMA_HOST = os.getenv("OLLAMA_HOST", "127.0.0.1")
15
+ OLLAMA_PORT = os.getenv("OLLAMA_PORT", "11434")
16
+ OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
17
+
18
+ # Gemini Settings
19
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
20
+
21
+ # Application Constants
22
+ EXAMPLES_DIR = "examples"
23
+ PROMPTS_FILE = "prompts.yaml"
24
+ FEATURES_FILE = "features.yaml"
25
+
26
+ # Define segments for prompt building
27
+ # (Category, Subcategory, Template Key)
28
+ FEATURE_SEQUENCE = [
29
+ ('identity', 'race', 'race'),
30
+ ('identity', 'class', 'class'),
31
+ ('identity', 'gender', 'gender'),
32
+ ('identity', 'age', 'age'),
33
+ ('expression_pose', 'expression', 'expression'),
34
+ ('expression_pose', 'pose', 'pose'),
35
+ ('appearance', 'hair_color', 'hair_color'),
36
+ ('appearance', 'hair_style', 'hair_style'),
37
+ ('appearance', 'eye_color', 'eye_color'),
38
+ ('appearance', 'build', 'build'),
39
+ ('appearance', 'skin_tone', 'skin_tone'),
40
+ ('appearance', 'distinguishing_feature', 'distinguishing_feature'),
41
+ ('equipment', 'armor', 'armor'),
42
+ ('equipment', 'weapon', 'weapon'),
43
+ ('equipment', 'accessory', 'accessory'),
44
+ ('equipment', 'accessory', 'accessory2'),
45
+ ('equipment', 'material', 'material'),
46
+ ('environment', 'background', 'background'),
47
+ ('environment', 'lighting', 'lighting'),
48
+ ('environment', 'atmosphere', 'atmosphere'),
49
+ ('vfx_style', 'vfx', 'vfx'),
50
+ ('vfx_style', 'style', 'style'),
51
+ ('vfx_style', 'mood', 'mood'),
52
+ ('vfx_style', 'camera', 'camera'),
53
+ ('technical', 'aspect_ratio', 'aspect_ratio')
54
+ ]
55
+
56
+ # Section names for extra info
57
+ SECTIONS = ['Identity', 'Appearance', 'Equipment', 'Environment', 'Style']
modules/core_logic.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import yaml
2
+ import random
3
+ import os
4
+ import json
5
+ import tempfile
6
+ import gradio as gr
7
+ from .config import FEATURES_FILE, FEATURE_SEQUENCE, SECTIONS, EXAMPLES_DIR
8
+
9
+ def load_features():
10
+ with open(FEATURES_FILE, "r") as f:
11
+ return yaml.safe_load(f)
12
+
13
+ features_data = load_features()
14
+
15
+ def get_detail(category, subcategory, key):
16
+ """Retrieves the detailed description for a given key in a category/subcategory."""
17
+ return features_data.get(category, {}).get(subcategory, {}).get(key, key)
18
+
19
+ def generate_prompt(*args):
20
+ """
21
+ Assembles the prompt based on dropdown selections and extra text info.
22
+ """
23
+ num_features = len(FEATURE_SEQUENCE)
24
+ feature_keys = args[:num_features]
25
+ extra_infos = args[num_features : num_features + len(SECTIONS)]
26
+
27
+ template = features_data.get("templates", {}).get("default", "")
28
+
29
+ context = {}
30
+ for i, (cat, subcat, t_key) in enumerate(FEATURE_SEQUENCE):
31
+ key = feature_keys[i]
32
+ context[t_key] = get_detail(cat, subcat, key)
33
+
34
+ # Handle multiple accessories dynamically
35
+ acc_list = []
36
+ # Identify accessory keys in context
37
+ for i, (cat, subcat, t_key) in enumerate(FEATURE_SEQUENCE):
38
+ if subcat == 'accessory' and feature_keys[i] != "None":
39
+ acc_list.append(context[t_key])
40
+
41
+ if acc_list:
42
+ context['accessories'] = ", and has " + " as well as ".join(acc_list)
43
+ else:
44
+ context['accessories'] = ""
45
+
46
+ # Inject extra info into the context
47
+ if extra_infos[0]: # Identity
48
+ context['age'] += f", {extra_infos[0]}"
49
+ if extra_infos[1]: # Appearance
50
+ context['distinguishing_feature'] += f", also {extra_infos[1]}"
51
+ if extra_infos[2]: # Equipment
52
+ if context['accessories']:
53
+ context['accessories'] += f", further complemented by {extra_infos[2]}"
54
+ else:
55
+ context['accessories'] = f", complemented by {extra_infos[2]}"
56
+ if extra_infos[3]: # Environment
57
+ context['atmosphere'] += f", additionally {extra_infos[3]}"
58
+ if extra_infos[4]: # Style
59
+ context['camera'] += f", art style notes: {extra_infos[4]}"
60
+
61
+ try:
62
+ return template.format(**context)
63
+ except Exception as e:
64
+ return f"Error building prompt: {e}"
65
+
66
+ def handle_regeneration(*args):
67
+ """Randomizes checkboxes and returns new values for dropdowns."""
68
+ num_features = len(FEATURE_SEQUENCE)
69
+ current_values = list(args[:num_features])
70
+ checkboxes = args[num_features : num_features*2]
71
+
72
+ new_values = []
73
+ for i, (is_random, (cat, subcat, t_key)) in enumerate(zip(checkboxes, FEATURE_SEQUENCE)):
74
+ if is_random:
75
+ choices = list(features_data[cat][subcat].keys())
76
+ new_values.append(random.choice(choices))
77
+ else:
78
+ new_values.append(current_values[i])
79
+
80
+ return new_values
81
+
82
+ def save_character(*args):
83
+ """Saves all current UI values to a JSON file."""
84
+ num_features = len(FEATURE_SEQUENCE)
85
+ feature_keys = args[:num_features]
86
+ checkboxes = args[num_features : num_features*2]
87
+ extra_infos = args[num_features*2 : num_features*2 + len(SECTIONS)]
88
+
89
+ data = {
90
+ "features": {FEATURE_SEQUENCE[i][2]: feature_keys[i] for i in range(num_features)},
91
+ "randomization": {FEATURE_SEQUENCE[i][2]: checkboxes[i] for i in range(num_features)},
92
+ "extra_info": {SECTIONS[i].lower(): extra_infos[i] for i in range(len(SECTIONS))}
93
+ }
94
+
95
+ # Save to a file with a friendly name in a temp directory
96
+ temp_dir = tempfile.mkdtemp()
97
+ path = os.path.join(temp_dir, "rpg_character_config.json")
98
+ with open(path, 'w') as f:
99
+ json.dump(data, f, indent=4)
100
+ return path
101
+
102
+ def load_character(file):
103
+ """Loads UI values from a JSON file."""
104
+ if file is None:
105
+ return [gr.update()] * (len(FEATURE_SEQUENCE) * 2 + len(SECTIONS))
106
+
107
+ try:
108
+ file_path = file.name if hasattr(file, 'name') else file
109
+ with open(file_path, 'r') as f:
110
+ data = json.load(f)
111
+
112
+ updates = []
113
+ # Update dropdowns
114
+ for _, _, t_key in FEATURE_SEQUENCE:
115
+ updates.append(data.get("features", {}).get(t_key, gr.update()))
116
+
117
+ # Update checkboxes (randomization flags)
118
+ for _, _, t_key in FEATURE_SEQUENCE:
119
+ updates.append(data.get("randomization", {}).get(t_key, gr.update()))
120
+
121
+ # Update extra info textboxes
122
+ for section in SECTIONS:
123
+ updates.append(data.get("extra_info", {}).get(section.lower(), ""))
124
+
125
+ return updates
126
+ except Exception as e:
127
+ print(f"Error loading character: {e}")
128
+ return [gr.update()] * (len(FEATURE_SEQUENCE) * 2 + len(SECTIONS))
129
+
130
+ def get_example_list():
131
+ """Returns a list of JSON filenames in the examples directory."""
132
+ if not os.path.exists(EXAMPLES_DIR):
133
+ return []
134
+ return [f for f in os.listdir(EXAMPLES_DIR) if f.endswith('.json')]
135
+
136
+ def load_example_character(filename):
137
+ """Loads a character config from the examples directory."""
138
+ if not filename:
139
+ return [gr.update()] * (len(FEATURE_SEQUENCE) * 2 + len(SECTIONS))
140
+
141
+ path = os.path.join(EXAMPLES_DIR, filename)
142
+ return load_character(path)
modules/integrations.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import requests
3
+ import json
4
+ import uuid
5
+ import time
6
+ import io
7
+ import yaml
8
+ import random
9
+ import traceback
10
+ import tempfile
11
+ from PIL import Image
12
+ from google import genai
13
+ from google.genai import types
14
+ from .config import (
15
+ GEMINI_API_KEY, OLLAMA_HOST, OLLAMA_PORT, COMFY_URL,
16
+ COMFY_WORKFLOW_FILE, PROMPTS_FILE
17
+ )
18
+
19
+ # Setup Gemini
20
+ client = None
21
+ gemini_active = False
22
+ if GEMINI_API_KEY:
23
+ try:
24
+ client = genai.Client(api_key=GEMINI_API_KEY)
25
+ gemini_active = True
26
+ except Exception as e:
27
+ print(f"Error initializing Gemini: {e}")
28
+
29
+ def load_system_prompt(key="refinement"):
30
+ """Loads a system prompt from prompts.yaml."""
31
+ try:
32
+ with open(PROMPTS_FILE, "r") as f:
33
+ prompts = yaml.safe_load(f)
34
+ return prompts.get(key, {}).get("system_instructions", "")
35
+ except Exception as e:
36
+ print(f"Error loading system prompt: {e}")
37
+ return ""
38
+
39
+ def get_ollama_models():
40
+ """Fetches available models from Ollama server and checks if it's running."""
41
+ url = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/tags"
42
+ try:
43
+ response = requests.get(url, timeout=2)
44
+ if response.status_code == 200:
45
+ models = response.json().get("models", [])
46
+ return [m["name"] for m in models]
47
+ return []
48
+ except Exception:
49
+ return []
50
+
51
+ def refine_with_gemini(prompt):
52
+ if not gemini_active:
53
+ return "Gemini API key not found in .env file."
54
+
55
+ system_prompt = load_system_prompt("refinement")
56
+ if not system_prompt:
57
+ system_prompt = (
58
+ "You are an expert prompt engineer for AI image generators. "
59
+ "Your task is to take the provided technical prompt and refine it into a more vivid, "
60
+ "artistic, and detailed description while maintaining all the core features."
61
+ )
62
+
63
+ try:
64
+ response = client.models.generate_content(
65
+ model="gemini-2.0-flash-exp",
66
+ config=types.GenerateContentConfig(
67
+ system_instruction=system_prompt,
68
+ temperature=0.7,
69
+ ),
70
+ contents=[prompt]
71
+ )
72
+ return response.text.strip()
73
+ except Exception as e:
74
+ return f"Error refining prompt with Gemini: {e}"
75
+
76
+ def refine_with_ollama(prompt, model):
77
+ """Refines the prompt using a local Ollama instance."""
78
+ system_prompt = load_system_prompt("refinement")
79
+ url = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}/api/generate"
80
+
81
+ payload = {
82
+ "model": model,
83
+ "prompt": f"{system_prompt}\n\nOriginal Prompt: {prompt}",
84
+ "stream": False
85
+ }
86
+
87
+ try:
88
+ response = requests.post(url, json=payload)
89
+ response.raise_for_status()
90
+ text = response.json().get("response", "").strip()
91
+ # Clean up potential markdown
92
+ if text.startswith("```"):
93
+ lines = text.splitlines()
94
+ if lines[0].startswith("```"): lines = lines[1:]
95
+ if lines and lines[-1].startswith("```"): lines = lines[:-1]
96
+ text = "\n".join(lines).strip()
97
+ return text
98
+ except Exception as e:
99
+ return f"Error refining prompt with Ollama: {e}"
100
+
101
+ def refine_master(prompt, backend, ollama_model):
102
+ """Routes prompt refinement to the selected backend."""
103
+ if not prompt.strip():
104
+ return ""
105
+ if backend == "Ollama (Local)":
106
+ return refine_with_ollama(prompt, ollama_model)
107
+ else:
108
+ return refine_with_gemini(prompt)
109
+
110
+ def generate_image_with_gemini(refined_prompt, technical_prompt, aspect_ratio):
111
+ if not gemini_active:
112
+ return None, None, "Gemini API key not found in .env file."
113
+
114
+ final_prompt = refined_prompt.strip() if refined_prompt and refined_prompt.strip() else technical_prompt.strip()
115
+
116
+ if not final_prompt:
117
+ return None, None, "No prompt available for generation."
118
+
119
+ try:
120
+ response = client.models.generate_images(
121
+ model='imagen-3.0-generate-001',
122
+ prompt=final_prompt,
123
+ config=types.GenerateImagesConfig(
124
+ aspect_ratio=aspect_ratio,
125
+ output_mime_type='image/png'
126
+ )
127
+ )
128
+ if response.generated_images:
129
+ img = Image.open(io.BytesIO(response.generated_images[0].image.image_bytes))
130
+ temp_dir = tempfile.mkdtemp()
131
+ img_path = os.path.join(temp_dir, "rpg_portrait.png")
132
+ img.save(img_path, "PNG")
133
+ return img, img_path, f"Image generated using {'refined' if refined_prompt.strip() else 'technical'} prompt!"
134
+ return None, None, "Gemini Image generation did not return any images."
135
+ except Exception as e:
136
+ traceback.print_exc()
137
+ return None, None, f"Image Generation Error: {e}"
138
+
139
+ def generate_image_with_comfy(prompt, aspect_ratio):
140
+ """Generates an image using a local ComfyUI instance."""
141
+ if not os.path.exists(COMFY_WORKFLOW_FILE):
142
+ return None, None, f"Error: Workflow file {COMFY_WORKFLOW_FILE} not found."
143
+
144
+ try:
145
+ with open(COMFY_WORKFLOW_FILE, 'r') as f:
146
+ workflow = json.load(f)
147
+
148
+ workflow["6"]["inputs"]["text"] = prompt
149
+
150
+ res_map = {
151
+ "1:1": (1024, 1024),
152
+ "16:9": (1344, 768),
153
+ "9:16": (768, 1344),
154
+ "4:3": (1152, 864),
155
+ "3:4": (864, 1152)
156
+ }
157
+ width, height = res_map.get(aspect_ratio, (1024, 1024))
158
+ workflow["13"]["inputs"]["width"] = width
159
+ workflow["13"]["inputs"]["height"] = height
160
+ workflow["38"]["inputs"]["seed"] = random.randint(1, 1125899906842624)
161
+
162
+ client_id = str(uuid.uuid4())
163
+ payload = {"prompt": workflow, "client_id": client_id}
164
+
165
+ response = requests.post(f"{COMFY_URL}/prompt", json=payload)
166
+ response.raise_for_status()
167
+ prompt_id = response.json().get("prompt_id")
168
+
169
+ max_retries = 60
170
+ for _ in range(max_retries):
171
+ hist_resp = requests.get(f"{COMFY_URL}/history/{prompt_id}")
172
+ if hist_resp.status_code == 200:
173
+ history = hist_resp.json()
174
+ if prompt_id in history:
175
+ outputs = history[prompt_id].get("outputs", {})
176
+ for node_id in outputs:
177
+ if "images" in outputs[node_id]:
178
+ image_data = outputs[node_id]["images"][0]
179
+ img_url = f"{COMFY_URL}/view?filename={image_data['filename']}&subfolder={image_data['subfolder']}&type={image_data['type']}"
180
+ img_resp = requests.get(img_url)
181
+ img_resp.raise_for_status()
182
+
183
+ img = Image.open(io.BytesIO(img_resp.content))
184
+ temp_dir = tempfile.mkdtemp()
185
+ img_path = os.path.join(temp_dir, "rpg_portrait_comfy.png")
186
+ img.save(img_path, "PNG")
187
+ return img, img_path, f"Image generated via ComfyUI!"
188
+ time.sleep(1)
189
+
190
+ return None, None, "ComfyUI generation timed out."
191
+
192
+ except Exception as e:
193
+ traceback.print_exc()
194
+ return None, None, f"ComfyUI Error: {e}"
195
+
196
+ def generate_image_master(refined_prompt, technical_prompt, aspect_ratio, backend):
197
+ """Routes image generation to the selected backend."""
198
+ if backend == "ComfyUI (Local)":
199
+ return generate_image_with_comfy(refined_prompt if refined_prompt.strip() else technical_prompt, aspect_ratio)
200
+ else:
201
+ return generate_image_with_gemini(refined_prompt, technical_prompt, aspect_ratio)
modules/ui_layout.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from .config import FEATURE_SEQUENCE, SECTIONS
3
+ from .core_logic import (
4
+ features_data, generate_prompt, handle_regeneration,
5
+ save_character, load_character, get_example_list, load_example_character
6
+ )
7
+ from .integrations import (
8
+ get_ollama_models, refine_master, generate_image_master
9
+ )
10
+
11
+ def build_ui():
12
+ with gr.Blocks(title="RPGPortrait Prompt Builder Pro") as demo:
13
+ gr.Markdown("# 🎨 RPGPortrait Pro")
14
+ gr.Markdown("Create professional AI prompts and generate portraits.")
15
+
16
+ dropdowns = []
17
+ checkboxes = []
18
+ extra_texts = []
19
+
20
+ def create_feature_ui(category, subcategory, label, default_value):
21
+ choices = list(features_data[category][subcategory].keys())
22
+ gr.Markdown(f"**{label}**")
23
+ with gr.Row(equal_height=True):
24
+ dd = gr.Dropdown(choices=choices, value=default_value, scale=12, show_label=False)
25
+ cb = gr.Checkbox(label="🎲", value=False, scale=1, min_width=50, container=False)
26
+ dropdowns.append(dd)
27
+ checkboxes.append(cb)
28
+
29
+ with gr.Row():
30
+ with gr.Column(scale=2):
31
+ with gr.Tabs():
32
+ with gr.TabItem("👤 Identity & Expression"):
33
+ gr.Markdown("### 👤 Identity")
34
+ create_feature_ui('identity', 'race', "Race", "Human")
35
+ create_feature_ui('identity', 'class', "Class", "Fighter")
36
+ create_feature_ui('identity', 'gender', "Gender", "Male")
37
+ create_feature_ui('identity', 'age', "Age", "Young Adult")
38
+ extra_id = gr.Textbox(placeholder="Extra Identity details (e.g. lineage, title)", label="Additional Identity Info")
39
+ extra_texts.append(extra_id)
40
+
41
+ gr.Markdown("### 🎭 Expression & Pose")
42
+ create_feature_ui('expression_pose', 'expression', "Expression", "Determined")
43
+ create_feature_ui('expression_pose', 'pose', "Pose", "Standing")
44
+
45
+ with gr.TabItem("🎨 Appearance"):
46
+ gr.Markdown("### 🎨 Appearance")
47
+ create_feature_ui('appearance', 'hair_color', "Hair Color", "Brown")
48
+ create_feature_ui('appearance', 'hair_style', "Hair Style", "Short")
49
+ create_feature_ui('appearance', 'eye_color', "Eye Color", "Brown")
50
+ create_feature_ui('appearance', 'build', "Build", "Average")
51
+ create_feature_ui('appearance', 'skin_tone', "Skin Tone", "Fair")
52
+ create_feature_ui('appearance', 'distinguishing_feature', "Distinguishing Feature", "None")
53
+ extra_app = gr.Textbox(placeholder="Extra Appearance details (e.g. tattoos, scars)", label="Additional Appearance Info")
54
+ extra_texts.append(extra_app)
55
+
56
+ with gr.TabItem("⚔️ Equipment"):
57
+ gr.Markdown("### ⚔️ Equipment")
58
+ create_feature_ui('equipment', 'armor', "Armor/Clothing", "Travelers Clothes")
59
+ create_feature_ui('equipment', 'weapon', "Primary Weapon", "Longsword")
60
+ create_feature_ui('equipment', 'accessory', "Accessory 1", "None")
61
+ create_feature_ui('equipment', 'accessory', "Accessory 2", "None")
62
+ create_feature_ui('equipment', 'material', "Material Detail", "Weathered")
63
+ extra_eq = gr.Textbox(placeholder="Extra Equipment details (e.g. weapon enchantments)", label="Additional Equipment Info")
64
+ extra_texts.append(extra_eq)
65
+
66
+ with gr.TabItem("🌍 Environment"):
67
+ gr.Markdown("### 🌍 Environment")
68
+ create_feature_ui('environment', 'background', "Background", "Forest")
69
+ create_feature_ui('environment', 'lighting', "Lighting", "Natural Sunlight")
70
+ create_feature_ui('environment', 'atmosphere', "Atmosphere", "Clear")
71
+ extra_env = gr.Textbox(placeholder="Extra Environment details (e.g. time of day, weather)", label="Additional Environment Info")
72
+ extra_texts.append(extra_env)
73
+
74
+ with gr.TabItem("🪄 Style & Technical"):
75
+ gr.Markdown("### 🪄 Style & Effects")
76
+ create_feature_ui('vfx_style', 'vfx', "Special Effects", "None")
77
+ create_feature_ui('vfx_style', 'style', "Art Style", "Digital Illustration")
78
+ create_feature_ui('vfx_style', 'mood', "Mood", "Heroic")
79
+ create_feature_ui('vfx_style', 'camera', "Camera Angle", "Bust")
80
+ extra_sty = gr.Textbox(placeholder="Extra Style details (e.g. specific artists, colors)", label="Additional Style Info")
81
+ extra_texts.append(extra_sty)
82
+
83
+ gr.Markdown("### ⚙️ Technical")
84
+ create_feature_ui('technical', 'aspect_ratio', "Aspect Ratio", "1:1")
85
+
86
+ gr.Markdown("---")
87
+
88
+ with gr.Row():
89
+ example_dropdown = gr.Dropdown(choices=get_example_list(), label="Example Characters", scale=3)
90
+ load_example_btn = gr.Button("📂 Load Example", variant="secondary", scale=1)
91
+ save_btn = gr.Button("💾 Save Character", variant="secondary", scale=1)
92
+ load_btn = gr.UploadButton("📂 Load Character", file_types=[".json"], variant="secondary", scale=1)
93
+
94
+ with gr.Group():
95
+ gr.Markdown("### ⚙️ Settings & Generation")
96
+ with gr.Row():
97
+ ollama_models = get_ollama_models()
98
+ ollama_active = len(ollama_models) > 0
99
+
100
+ refinement_backend = gr.Radio(
101
+ choices=["Gemini (Cloud)", "Ollama (Local)"] if ollama_active else ["Gemini (Cloud)"],
102
+ value="Gemini (Cloud)",
103
+ label="Prompt Refinement Backend",
104
+ scale=2
105
+ )
106
+
107
+ ollama_model_dropdown = gr.Dropdown(
108
+ choices=ollama_models,
109
+ value=ollama_models[0] if ollama_active else None,
110
+ label="Ollama Model",
111
+ visible=False,
112
+ scale=1
113
+ )
114
+
115
+ with gr.Row():
116
+ backend_selector = gr.Radio(
117
+ choices=["Gemini (Cloud)", "ComfyUI (Local)"],
118
+ value="Gemini (Cloud)",
119
+ label="Image Generation Backend",
120
+ scale=2
121
+ )
122
+ with gr.Column(scale=1):
123
+ refine_btn = gr.Button("🧠 Refine Prompt", variant="primary")
124
+ gen_img_btn = gr.Button("🖼️ Generate Image", variant="primary")
125
+
126
+ with gr.Column(scale=1):
127
+ gr.Markdown("### 📝 Prompts & Output")
128
+ prompt_output = gr.Textbox(label="Generated Technical Prompt", lines=4, interactive=False)
129
+ regenerate_btn = gr.Button("✨ Randomize Features", variant="secondary")
130
+ refined_output = gr.Textbox(label="Refined Artistic Prompt", lines=6, interactive=True)
131
+
132
+ gr.Markdown("---")
133
+ image_output = gr.Image(label="Portrait", show_label=False)
134
+ download_img_btn = gr.DownloadButton("📥 Download Portrait (PNG)", variant="secondary", visible=False)
135
+ status_msg = gr.Markdown("")
136
+ download_file = gr.File(label="Saved Character JSON", visible=False)
137
+
138
+ all_input_components = dropdowns + extra_texts
139
+
140
+ for comp in all_input_components:
141
+ comp.change(fn=generate_prompt, inputs=all_input_components, outputs=prompt_output)
142
+
143
+ regenerate_btn.click(
144
+ fn=handle_regeneration,
145
+ inputs=dropdowns + checkboxes,
146
+ outputs=dropdowns
147
+ ).then(
148
+ fn=generate_prompt,
149
+ inputs=all_input_components,
150
+ outputs=prompt_output
151
+ ).then(
152
+ fn=lambda: "",
153
+ outputs=refined_output
154
+ )
155
+
156
+ refine_btn.click(
157
+ fn=refine_master,
158
+ inputs=[prompt_output, refinement_backend, ollama_model_dropdown],
159
+ outputs=refined_output
160
+ )
161
+
162
+ gen_img_btn.click(
163
+ fn=generate_image_master,
164
+ inputs=[refined_output, prompt_output, dropdowns[-1], backend_selector],
165
+ outputs=[image_output, download_img_btn, status_msg]
166
+ ).then(
167
+ fn=lambda x: gr.update(value=x, visible=True) if x else gr.update(visible=False),
168
+ inputs=download_img_btn,
169
+ outputs=download_img_btn
170
+ )
171
+
172
+ refinement_backend.change(
173
+ fn=lambda b: gr.update(visible=(b == "Ollama (Local)")),
174
+ inputs=refinement_backend,
175
+ outputs=ollama_model_dropdown
176
+ )
177
+
178
+ save_btn.click(
179
+ fn=save_character,
180
+ inputs=dropdowns + checkboxes + extra_texts,
181
+ outputs=download_file
182
+ ).then(
183
+ fn=lambda: gr.update(visible=True),
184
+ outputs=download_file
185
+ )
186
+
187
+ load_btn.upload(
188
+ fn=load_character,
189
+ inputs=load_btn,
190
+ outputs=dropdowns + checkboxes + extra_texts
191
+ ).then(
192
+ fn=generate_prompt,
193
+ inputs=all_input_components,
194
+ outputs=prompt_output
195
+ )
196
+
197
+ load_example_btn.click(
198
+ fn=load_example_character,
199
+ inputs=example_dropdown,
200
+ outputs=dropdowns + checkboxes + extra_texts
201
+ ).then(
202
+ fn=generate_prompt,
203
+ inputs=all_input_components,
204
+ outputs=prompt_output
205
+ )
206
+
207
+ demo.load(fn=generate_prompt, inputs=all_input_components, outputs=prompt_output)
208
+
209
+ return demo