From a655feff9ef82de6032d4d1d4e93490922edd67b Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 14 May 2026 10:23:47 +0200 Subject: [PATCH] Initial Commit --- CLI/3d-model-image-prompt-generator.py | 262 +++ generate_equirect.py | 142 ++ image_to_3d.py | 218 +++ main.py | 1241 +++++++++++++ requirements.txt | 12 + run.sh | 30 + scene_setup.py | 77 + web/index.html | 2375 ++++++++++++++++++++++++ 8 files changed, 4357 insertions(+) create mode 100644 CLI/3d-model-image-prompt-generator.py create mode 100644 generate_equirect.py create mode 100644 image_to_3d.py create mode 100644 main.py create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 scene_setup.py create mode 100644 web/index.html diff --git a/CLI/3d-model-image-prompt-generator.py b/CLI/3d-model-image-prompt-generator.py new file mode 100644 index 0000000..e35ca7e --- /dev/null +++ b/CLI/3d-model-image-prompt-generator.py @@ -0,0 +1,262 @@ +import os +import sys +import requests +import time +import json +import re +import subprocess + +from langchain_community.llms import Ollama + +SD_WEBUI_PATH = "/Users/giers/Tools/stable-diffusion-webui" + +def is_sd_webui_running(): + try: + r = requests.get("http://127.0.0.1:7860/sdapi/v1/txt2img", timeout=3) + # Should error because POST is required, but if it responds, it's running + return True + except Exception: + return False + +def start_sd_webui_headless(webui_dir): + # Use --headless, disable extensions/UIs for fastest startup + args = [ + "python3", "launch.py", + "--nowebui", # Don't launch browser UI + "--headless", # No local UI window + "--api", # Enable API + "--skip-torch-cuda-test", + "--no-hashing", # Faster startup + "--disable-nan-check", # Optional: faster + "--xformers" + ] + proc = subprocess.Popen( + args, + cwd=webui_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + return proc + +def get_output_dir(): + out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "output") + os.makedirs(out_dir, exist_ok=True) + return out_dir + + +def get_next_available_filename(base, ext=".png"): + out_dir = get_output_dir() + i = 1 + while True: + fname = f"{base}-{i}{ext}" + fpath = os.path.join(out_dir, fname) + if not os.path.exists(fpath): + return fpath + i += 1 + +def flush_print(*args, **kwargs): + print(*args, **kwargs) + sys.stdout.flush() + +OLLAMA_URL = "http://localhost:11434/api/generate" +#MODEL = "mistral-small3.1:24b" +MODEL = "mistral:latest" +SD_URL = "http://127.0.0.1:7860/sdapi/v1/txt2img" + +META_PROMPT = """ +You are an expert at writing concise, detailed Stable Diffusion prompts for 3D rendered objects as seen in professional game development. When I give you an object name, carefully follow these instructions step by step: + +1. Subject ({{OBJECT}}): +- Start with the object name ({{OBJECT}}). +- Add 2–3 specific visual or material details (for example: shape, surface texture, design features like “chrome plating,” “organic armor,” “glowing eyes”). + +2. Medium & Style: +- Add keywords for a 3D render and concept art look: + concept art, 3D render, game asset, professional game designer, digital sculpture, hyperrealistic, octane render, Unreal Engine 5, high poly. + +3. Presentation: +- Explicitly state the object is shown fully in frame, not cropped, and completely visible from a neutral angle. +- No background: isolated on pure white background, or transparent background. + +4. Lighting & Quality: +- Use studio lighting, no dramatic shadows, no depth of field, no blur. +- Emphasize sharp focus, ultra detailed, 8K, clean silhouette. + +5. Artist Influence (optional): +- If fitting, add a well-known concept artist (example: by Beeple). + +6. Negative Prompt: +- Add: Negative prompt: blurry, lowres, bad anatomy, distorted proportions, background, scenery, environment, artifacting, watermark, text, cropped, partial view, depth of field, out of frame, blur, vignette + +Output Format: +- Line 1: All positive prompt keywords (subject, details, medium, style, lighting, quality, artist, presentation, background). +- Line 2: Start with Negative prompt: and then the negative keywords above. + +Instructions to the LLM: +- The first line must always start with the object and its details. +- The object must be fully visible, not cropped, and entirely in the image frame. +- There must be no background, and the background must be pure white or transparent. +- Do NOT use blur, depth of field, vignette, or any visual effects that obscure details. +- Do NOT add any scenery or environment. +- Be concise and avoid repetition. + +Now, generate a Stable Diffusion prompt for the object: {{OBJECT}}. +""" + +SECOND_PROMPT_TEMPLATE = """ +You are a prompt-to-JSON converter for image generation tasks. + +Given a Stable Diffusion prompt, extract the positive and negative prompts. +Also, analyze the subject: +- If it is a single character, creature, full object, or person standing/upright, set "dimensions" to [512,768] (vertical). +- If it is a landscape, wide object, multi-character group, or scene, set "dimensions" to [768,512] (horizontal). +- If it is something square or best seen as a square (e.g., shield, logo, face, emblem, single centered item), set "dimensions" to [768,768] (square). + +Return a JSON object with these fields: +{ + "positive prompt": "...", + "negative prompt": "...", + "dimensions": [W,H] +} + +Respond only with the JSON. +Here is the Stable Diffusion prompt: + +{PROMPT} +""" + + +# --- streaming LLM utility --- +def stream_ollama(prompt): + payload = {"model": MODEL, "prompt": prompt, "stream": True} + with requests.post(OLLAMA_URL, json=payload, stream=True, timeout=300) as r: + r.raise_for_status() + full_response = "" + for line in r.iter_lines(): + if line: + try: + chunk = json.loads(line.decode("utf-8"))["response"] + except Exception: + chunk = line.decode("utf-8") + print(chunk, end="", flush=True) + full_response += chunk + print() + return full_response.strip() + +def get_json_from_llm(prompt): + ollama_chain = Ollama(model=MODEL, base_url="http://localhost:11434") + response_gen = ollama_chain.stream(prompt) + json_output = "" + for chunk in response_gen: + print(chunk, end="", flush=True) + json_output += chunk + print() + # Try to extract JSON from output, even if LLM includes code block markers + json_start = json_output.find("{") + json_end = json_output.rfind("}") + if json_start != -1 and json_end != -1: + json_str = json_output[json_start:json_end+1] + try: + data = json.loads(json_str) + return data + except Exception as e: + print("Error parsing JSON:", e) + return None + print("Failed to extract JSON!") + return None + +def sanitize_filename(s): + # Replace spaces with underscores, remove non-alphanum chars + s = s.strip().lower().replace(" ", "_") + return re.sub(r'[^a-z0-9_]+', '', s) + +def call_stable_diffusion(prompt_json, user_input): + from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler + import torch + + # *** Use your diffusers-converted path! *** + model_path = "/Volumes/SD/ML-Models/diffusers/dreamshaper_8_diffusers" + + if torch.backends.mps.is_available(): + device = "mps" + print("Using Apple Silicon MPS backend") + else: + device = "cpu" + print("Warning: Running on CPU (slow)") + + print("Loading model, this may take a while the first time...", flush=True) + pipe = StableDiffusionPipeline.from_pretrained( + model_path, + torch_dtype=torch.float32, + safety_checker=None, + local_files_only=True, + ) + pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) + pipe = pipe.to(device) + pipe.set_progress_bar_config(disable=True) + pipe.enable_attention_slicing() + + prompt = prompt_json["positive prompt"][:75] + negative_prompt = prompt_json["negative prompt"][:75] + width, height = prompt_json["dimensions"] + num_images = 4 + steps = prompt_json.get("steps", 30) + guidance_scale = prompt_json.get("cfg_scale", 6.5) + + print(f"Generating {num_images} image(s)...", flush=True) + images = pipe( + prompt=[prompt]*num_images, + negative_prompt=[negative_prompt]*num_images, + width=width, + height=height, + num_inference_steps=steps, + guidance_scale=guidance_scale, + ).images + + img_base = sanitize_filename(user_input) + for idx, img in enumerate(images, start=1): + filename = get_next_available_filename(f"{img_base}-{idx}") + img.save(filename) + print(f"Saved: {filename}") + +def main(): + try: + if len(sys.argv) < 2: + print("Usage: python 3d-model-image-prompt-generator.py \"object name\"", flush=True) + sys.exit(1) + + object_name = sys.argv[1] + print(f"\n--- Generating Stable Diffusion prompt for: {object_name} ---\n", flush=True) + prompt = META_PROMPT.replace("{{OBJECT}}", object_name) + + # 1. SD Prompt Generation (streamed) + sd_prompt = stream_ollama(prompt) + print("\n--- End of Stable Diffusion prompt ---\n", flush=True) + + # 2. JSON Conversion (streamed) + print("--- Generating JSON for image generation ---\n", flush=True) + second_prompt = SECOND_PROMPT_TEMPLATE.replace("{PROMPT}", sd_prompt) + prompt_json = get_json_from_llm(second_prompt) + if not prompt_json: + print("Failed to get valid JSON. Exiting.", flush=True) + sys.exit(1) + + # 3. Augment JSON with hard-coded SD settings + prompt_json["checkpoint"] = "RealismPlus/dreamshaper_8.safetensors" + prompt_json["vae"] = "Automatic" + prompt_json["sampler"] = "DPM++ 2M Karras" + prompt_json["steps"] = 30 + prompt_json["cfg_scale"] = 6.5 + + print("\n--- Final prompt JSON ---\n", flush=True) + print(json.dumps(prompt_json, indent=2, ensure_ascii=False), flush=True) + + # 4. Call local Stable Diffusion via diffusers + call_stable_diffusion(prompt_json, object_name) + except Exception as e: + import traceback + print("Exception in main():", e, flush=True) + traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/generate_equirect.py b/generate_equirect.py new file mode 100644 index 0000000..9d12e60 --- /dev/null +++ b/generate_equirect.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +import argparse +import os +import subprocess +import torch +from PIL import Image, ImageDraw +import shutil +import tempfile + +from diffusers import ( + StableDiffusionPipeline, + DPMSolverMultistepScheduler, + StableDiffusionInpaintPipeline +) + +def shift_image(img: Image.Image, shift: int) -> Image.Image: + w, h = img.size + out = Image.new("RGB", (w, h)) + out.paste(img.crop((shift, 0, w, h)), (0, 0)) + out.paste(img.crop((0, 0, shift, h)), (w - shift, 0)) + return out + +def create_mask(width: int, height: int, mask_w: int) -> Image.Image: + mask = Image.new("L", (width, height), 0) + draw = ImageDraw.Draw(mask) + left = (width - mask_w) // 2 + draw.rectangle([left, 0, left + mask_w, height], fill=255) + return mask + +def unshift_image(img: Image.Image, shift: int) -> Image.Image: + w, h = img.size + out = Image.new("RGB", (w, h)) + out.paste(img.crop((w - shift, 0, w, h)), (0, 0)) + out.paste(img.crop((0, 0, w - shift, h)), (shift, 0)) + return out + +def main(): + parser = argparse.ArgumentParser( + description="Generate an equirectangular HDRI, make it seamless, and upscale it with Topaz Photo AI CLI." + ) + parser.add_argument("--prompt", required=True, + help="Text prompt for generation and inpainting") + parser.add_argument("--output", required=True, + help="Filename for the final upscaled image (e.g. seamless.png)") + parser.add_argument("--work-dir", default=os.path.dirname(os.path.abspath(__file__)), + help="Working directory for intermediates and final outputs") + args = parser.parse_args() + + # Output-Ordner (bleibt wie gehabt) + output_abs = os.path.abspath(args.output) + + # Zwischenschritte landen im eigenem temp-Ordner: + with tempfile.TemporaryDirectory(dir=args.work_dir) as tempdir: + print(f"→ Using tempdir: {tempdir}") + + model_path = "/Volumes/SD/ML-Models/diffusers/hdri-panorama-v1-diffusers" + topaz_cli = "/Applications/Topaz Photo AI.app/Contents/MacOS/Topaz Photo AI" + steps = 20 + scale = 7.0 + width, height = 1024, 512 + + if torch.backends.mps.is_available(): + device = "mps" + elif torch.cuda.is_available(): + device = "cuda" + else: + device = "cpu" + + # 1) Generate base HDRI + gen_pipe = StableDiffusionPipeline.from_pretrained( + model_path, + torch_dtype=torch.float32 + ).to(device) + gen_pipe.scheduler = DPMSolverMultistepScheduler.from_config(gen_pipe.scheduler.config) + gen_pipe.enable_attention_slicing() + + print("→ Generating equirectangular HDRI…") + image = gen_pipe( + prompt=args.prompt, + num_inference_steps=steps, + guidance_scale=scale-1.5, + width=width, + height=height + ).images[0] + + gen_path = os.path.join(tempdir, f"base_{width}x{height}.png") + image.save(gen_path) + print(f"→ Saved initial image to {gen_path}") + + # 2) Make it seamless + shift_amt = width // 2 + mask_w = width // 8 + + shifted = shift_image(image, shift_amt) + mask = create_mask(width, height, mask_w) + + inpaint_pipe = StableDiffusionInpaintPipeline.from_pretrained( + "Lykon/dreamshaper-8-inpainting", + torch_dtype=torch.float32 + ).to(device) + inpaint_pipe.enable_attention_slicing() + + print("→ Inpainting seam for seamless tiling…") + inpainted = inpaint_pipe( + prompt=args.prompt, + image=shifted, + mask_image=mask, + num_inference_steps=steps, + guidance_scale=scale, + width=width, + height=height + ).images[0] + + seamless_path = os.path.join(tempdir, os.path.basename(args.output)) + inpainted = unshift_image(inpainted, shift_amt) + inpainted.save(seamless_path) + print(f"→ Crafted seamless image: {seamless_path}") + + # 3) Upscale with Topaz Photo AI CLI + print("→ Upscaling with Topaz Photo AI CLI…") + result = subprocess.run( + [topaz_cli, "--cli", seamless_path, "-o", tempdir], + check=True + ) + + # Finde das letzte erstellte PNG im tempdir (das ist das hochskalierte!) + # Topaz kann einen Suffix anhängen, falls der Name schon existiert. + upscaled_files = sorted( + [os.path.join(tempdir, f) for f in os.listdir(tempdir) if f.lower().endswith(".png")], + key=os.path.getmtime, + reverse=True + ) + if not upscaled_files: + print("→ No PNG output found in tempdir after Topaz run!") + return + + upscaled = upscaled_files[0] + shutil.move(upscaled, output_abs) + print(f"→ Upscaled image moved to {output_abs}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/image_to_3d.py b/image_to_3d.py new file mode 100644 index 0000000..20758e7 --- /dev/null +++ b/image_to_3d.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +import replicate +import requests +from PIL import Image + +MODEL_NAME = "tencent/hunyuan-3d-3.1" +TIMEOUT = 900 +PREDICTION_TIMEOUT = 10 * 60 +POLL_INTERVAL = 2.0 +MAX_INPUT_BYTES = 6 * 1024 * 1024 +MAX_DATA_URI_BYTES = 1024 * 1024 +MAX_INPUT_SIDE = 2048 +SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} + + +def notify(title: str, message: str) -> None: + script = f"display notification {json.dumps(message)} with title {json.dumps(title)}" + try: + subprocess.run(["osascript", "-e", script], check=False) + except OSError: + pass + + +def _has_alpha(img: Image.Image) -> bool: + return img.mode in {"RGBA", "LA"} or ( + img.mode == "P" and "transparency" in img.info + ) + + +def _save_compact_image(img: Image.Image, output_path: str) -> None: + if max(img.size) > MAX_INPUT_SIDE: + img = img.copy() + img.thumbnail((MAX_INPUT_SIDE, MAX_INPUT_SIDE), Image.Resampling.LANCZOS) + + if _has_alpha(img): + img.save(output_path, "WEBP", quality=95, method=6) + return + + if img.mode != "RGB": + img = img.convert("RGB") + img.save(output_path, "JPEG", quality=92, optimize=True) + + +def prepare_input_image(src_path: str, temp_paths: list[str]) -> str: + ext = Path(src_path).suffix.lower() + if ext in SUPPORTED_EXTENSIONS and os.path.getsize(src_path) <= MAX_DATA_URI_BYTES: + return src_path + + img = Image.open(src_path) + suffix = ".webp" if _has_alpha(img) else ".jpg" + fd, temp_path = tempfile.mkstemp(suffix=suffix) + os.close(fd) + _save_compact_image(img, temp_path) + temp_paths.append(temp_path) + + if os.path.getsize(temp_path) > MAX_INPUT_BYTES: + raise RuntimeError("Prepared image is still larger than Replicate's 6MB limit.") + + return temp_path + + +def run_replicate(image_path: str, api_token: str) -> Any: + client = replicate.Client(api_token=api_token) + client.poll_interval = POLL_INTERVAL + + with open(image_path, "rb") as image_file: + prediction = client.models.predictions.create( + model=MODEL_NAME, + input={ + "image": image_file, + "generate_type": "Normal", + "face_count": 500000, + "enable_pbr": False, + }, + wait=False, + file_encoding_strategy="base64", + ) + + print(f"Replicate prediction started: {prediction.id} ({prediction.status})") + deadline = time.monotonic() + PREDICTION_TIMEOUT + last_status = prediction.status + + while prediction.status not in {"succeeded", "failed", "canceled"}: + if time.monotonic() >= deadline: + raise TimeoutError( + f"Timed out waiting for Replicate prediction {prediction.id} " + f"after {PREDICTION_TIMEOUT // 60} minutes. It may still be running." + ) + + time.sleep(client.poll_interval) + prediction.reload() + + if prediction.status != last_status: + print(f"Replicate prediction {prediction.id}: {prediction.status}") + last_status = prediction.status + + if prediction.status != "succeeded": + detail = prediction.error or prediction.logs or f"status={prediction.status}" + raise RuntimeError(f"Replicate prediction {prediction.id} failed: {detail}") + + return prediction.output + + +def _extract_output_file(output: Any) -> Any: + if isinstance(output, (list, tuple)): + if not output: + raise RuntimeError("Replicate returned an empty output.") + return output[0] + + if isinstance(output, dict): + for key in ("output", "model", "mesh", "glb", "url"): + if output.get(key): + return _extract_output_file(output[key]) + raise RuntimeError(f"Replicate returned an unsupported output shape: {output}") + + return output + + +def _bytes_from_url(url: str) -> bytes: + if url.startswith("data:"): + _, encoded = url.split(",", 1) + return base64.b64decode(encoded) + + response = requests.get(url, timeout=TIMEOUT) + response.raise_for_status() + return response.content + + +def write_output(output: Any, output_path: str) -> None: + output_file = _extract_output_file(output) + + if hasattr(output_file, "read"): + data = output_file.read() + elif hasattr(output_file, "url"): + data = _bytes_from_url(str(output_file.url)) + elif isinstance(output_file, str): + data = _bytes_from_url(output_file) + else: + raise RuntimeError(f"Replicate returned an unsupported output type: {type(output_file)!r}") + + with open(output_path, "wb") as f: + f.write(data) + + +def process_image(img_path: str, api_token: str | None = None) -> str | None: + api_token = (api_token or os.environ.get("REPLICATE_API_TOKEN", "")).strip() + if not api_token: + msg = "Missing Replicate API token. Add it in app settings or set REPLICATE_API_TOKEN." + print(msg) + notify("3D conversion error", msg) + return None + + img_path = os.path.abspath(img_path) + if not os.path.isfile(img_path): + msg = f"Image not found: {img_path}" + print(msg) + notify("3D conversion error", msg) + return None + + temp_paths: list[str] = [] + try: + input_path = prepare_input_image(img_path, temp_paths) + base_name = os.path.splitext(os.path.basename(img_path))[0] + output_path = os.path.join(os.path.dirname(img_path), f"{base_name}.glb") + + print(f"Running {MODEL_NAME} on Replicate...") + output = run_replicate(input_path, api_token) + write_output(output, output_path) + + msg = f"3D model saved: {output_path}" + print(msg) + notify("3D conversion complete", msg) + return output_path + except Exception as e: + msg = f"3D conversion failed for {img_path}: {e}" + print(msg) + notify("3D conversion error", msg) + return None + finally: + for temp_path in temp_paths: + try: + os.remove(temp_path) + except OSError: + pass + + +def main() -> None: + if len(sys.argv) < 2: + msg = "Usage: python image_to_3d.py [image2 ...]" + notify("3D conversion error", "No image files provided.") + print(msg) + sys.exit(1) + + outputs = [] + for img_path in sys.argv[1:]: + print(f"\nProcessing: {img_path}") + result = process_image(img_path) + if result: + outputs.append(result) + + if not outputs: + sys.exit(1) + + notify("3D conversion", f"Finished {len(outputs)} model(s).") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..0e9d240 --- /dev/null +++ b/main.py @@ -0,0 +1,1241 @@ +import os +import sys +import json +import re +import requests +import threading +import time +import base64 +import io +import webview +import random +import subprocess +import concurrent.futures +from PIL import Image, PngImagePlugin +from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler +import torch +import replicate + +# ------------- Configuration ------------- + +OLLAMA_URL = "http://localhost:11434/api/generate" +MODEL = "mistral:latest" # or "mistral-small3.1:24b" + +# Path to your diffusers-converted model +MODEL_PATH = "/Volumes/SD/ML-Models/diffusers/dreamshaper_8_diffusers" + +# Where to save generated images +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# ──────────────────────────────────────────────────────────────────────────── +# Store everything under web/output instead of project_root/output +WEB_DIR = os.path.join(BASE_DIR, "web") +OUTPUT_DIR = os.path.join(WEB_DIR, "output") +os.makedirs(OUTPUT_DIR, exist_ok=True) +APP_SUPPORT_DIR = os.path.join( + os.path.expanduser("~"), + "Library", + "Application Support", + "3d-model-generator", +) +SETTINGS_PATH = os.path.join(APP_SUPPORT_DIR, "settings.json") +# ──────────────────────────────────────────────────────────────────────────── + +IMAGE_BACKEND_LOCAL = "local" +IMAGE_BACKEND_REPLICATE = "replicate" +DEFAULT_IMAGE_BACKEND = IMAGE_BACKEND_LOCAL +DEFAULT_REPLICATE_IMAGE_MODEL = "google/imagen-4-fast" +REPLICATE_IMAGE_TIMEOUT = 10 * 60 +REPLICATE_IMAGE_POLL_INTERVAL = 2.0 + +REPLICATE_IMAGE_MODELS = { + "black-forest-labs/flux-schnell": { + "label": "FLUX.1 schnell", + "price": "$3 / 1000 output images (~$0.003/image)", + "aspect_ratios": ["1:1", "16:9", "21:9", "3:2", "2:3", "4:5", "5:4", "3:4", "4:3", "9:16", "9:21"], + }, + "google/imagen-4-fast": { + "label": "Google Imagen 4 Fast", + "price": "$0.02 / output image", + "aspect_ratios": ["1:1", "9:16", "16:9", "3:4", "4:3"], + }, + "bytedance/seedream-4": { + "label": "ByteDance Seedream 4", + "price": "$0.03 / output image", + "aspect_ratios": ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"], + }, +} + + +def load_app_settings() -> dict: + try: + with open(SETTINGS_PATH, "r", encoding="utf-8") as f: + settings = json.load(f) + return settings if isinstance(settings, dict) else {} + except FileNotFoundError: + return {} + except Exception: + return {} + + +def save_app_settings(settings: dict) -> None: + os.makedirs(APP_SUPPORT_DIR, exist_ok=True) + with open(SETTINGS_PATH, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=2) + +# ------------- Prompt Templates ------------- + +META_PROMPT = """ +You are an expert at writing concise, detailed Stable Diffusion prompts for 3D rendered objects, as seen in professional game development. + +When I give you an object name (which may already contain style or material cues, e.g. "low poly farmer" or "pixel art sword"), follow these rules: + +1. Parse the object name: +- If the object name includes a style, technique, or specific look (e.g., low poly, pixel art, voxel, cartoon, realistic, hand painted, clay, wireframe, etc.), make that style the focus of the prompt. +- Do NOT add conflicting style keywords. (E.g. do NOT add high poly if the input is low poly.) +- If the object name is generic (like farmer or spaceship), you may suggest professional, detailed, high-quality, realistic or stylized game art (choose one style, do NOT mix). +- Add 2–3 relevant visual or material details to the object, based on typical game art conventions. + +2. Medium & Style: +- If the object name includes a medium, engine, or tool (Unreal, Octane, Unity, voxel, etc.), use it and do NOT add others. +- Otherwise, you may choose (but do NOT mix) one or two: concept art, 3D render, game asset, professional game designer, digital sculpture, etc. + +3. Presentation: +- State that the object is fully visible, not cropped, and centered from a neutral angle. +- No background: isolated on pure white or transparent background. + +4. Lighting & Quality: +- Only add studio lighting, sharp focus, clean silhouette, ultra detailed, 8K, etc. if compatible with the style. +- Avoid depth of field, blur, or effects that obscure details, unless specifically requested. + +5. Negative Prompt: +- Always add: Negative prompt: blurry, lowres, bad anatomy, distorted proportions, background, scenery, environment, artifacting, watermark, text, cropped, partial view, depth of field, out of frame, blur, vignette + +Output Format: +- Line 1: All positive prompt keywords (subject, details, medium, style, lighting, quality, presentation, background). +- Line 2: Negative prompt: then the negative keywords above. + +Instructions: +- If the input object already specifies a style or technique, your prompt must reflect that and avoid all contradictory terms. +- Always keep the style consistent. +- Avoid unnecessary repetition. +- Never add stylistic terms that contradict or dilute the user’s intention. +- The positive prompt must not contain more than 300 characters. Make it just fit 300 characters, not too long, not too short but perfect for a Stable Diffusion prompt. +- If the prompt is short, come up with creative ideas on how to make the character or object more intricate and interesting, suitable for game design. Add creative details to what the presented things might look like! +- Use positive descriptions of what should be visible in the positive prompt, don't use negations there. +- Most importantly, the prompts must make sure in some way that the object or character is FULLY visible (not cropped) on a blank background. + +Now, generate a Stable Diffusion prompt for the object: {{OBJECT}} +""" + +SECOND_PROMPT_TEMPLATE = """ +You are a prompt-to-JSON converter for image generation tasks. + +Given a Stable Diffusion prompt, extract the positive and negative prompts. +If the prompt is longer than 300 characters, summarize it so that it's less than 300 characters long. +If you need to shorten your prompt, always remove less relevant visual details first. +Never remove the presentation instructions (e.g., fully visible, isolated, centered, white background); these must always remain at the end of the positive prompt. +Remove words that are followed by a semicolon (i.e. "Presentation:" or "Lighting & Quality:") - these things have no business in a Stable Diffusion prompt. + +Also, analyze the subject: +- If it is a single character, an upright (bipedal) creature, full object, a person, or anything similar, set "dimensions" to [512,768] (vertical). (In that case of a bipedal creature / human, you might also add "A-Pose" to the prompt!) +- If it is a landscape, wide object, multi-character group, or scene, or a creature that isn't tall (four-legged creatures for example), set "dimensions" to [768,512] (horizontal). +- If it is something square or best seen as a square (e.g., shield, logo, face, emblem, single centered item or a box or round-shaped creature), set "dimensions" to [768,768] (square). + +Return a JSON object with these fields: +{ + "positive prompt": "...", + "negative prompt": "...", + "dimensions": [W,H] +} + +Respond only with the JSON. +Here is the Stable Diffusion prompt: + +{PROMPT} +""" + +# ------------- Helper Functions ------------- + +def stream_ollama(prompt: str): + """ + Stream tokens from Ollama. + Yields the cumulative response string after each new chunk arrives. + """ + payload = {"model": MODEL, "prompt": prompt, "stream": True} + with requests.post(OLLAMA_URL, json=payload, stream=True, timeout=600) as r: + r.raise_for_status() + full_response = "" + for line in r.iter_lines(): + if line: + try: + chunk = json.loads(line.decode("utf-8"))["response"] + except Exception: + chunk = line.decode("utf-8") + full_response += chunk + yield full_response + yield full_response.strip() + +def get_json_from_llm(prompt: str): + """ + Stream the JSON conversion call to Ollama (no LangChain). + Yields the cumulative response string after each chunk arrives. + """ + payload = {"model": MODEL, "prompt": prompt, "stream": True} + with requests.post(OLLAMA_URL, json=payload, stream=True, timeout=600) as r: + r.raise_for_status() + full_response = "" + for line in r.iter_lines(): + if line: + try: + chunk = json.loads(line.decode("utf-8"))["response"] + except Exception: + chunk = line.decode("utf-8") + full_response += chunk + yield full_response + yield full_response.strip() + +def sanitize_filename(s: str) -> str: + s = s.strip().lower().replace(" ", "_") + return re.sub(r'[^a-z0-9_]+', '', s) + +def get_next_available_filename(base: str, ext: str = ".png") -> str: + i = 1 + while True: + fname = f"{base}-{i}{ext}" + # → hier steht noch fpath = f"output/{fname}" + fpath = os.path.join(OUTPUT_DIR, fname) + if not os.path.exists(fpath): + return fpath + i += 1 + +def image_to_base64(path: str) -> str: + """ + Read an image from disk and return a data: URI string. + """ + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + return f"data:image/png;base64,{b64}" + + +def normalize_image_backend(value: str | None) -> str: + value = (value or DEFAULT_IMAGE_BACKEND).strip().lower() + if value in {IMAGE_BACKEND_LOCAL, IMAGE_BACKEND_REPLICATE}: + return value + return DEFAULT_IMAGE_BACKEND + + +def normalize_replicate_image_model(value: str | None) -> str: + value = (value or DEFAULT_REPLICATE_IMAGE_MODEL).strip() + if value in REPLICATE_IMAGE_MODELS: + return value + return DEFAULT_REPLICATE_IMAGE_MODEL + + +def nearest_aspect_ratio(width: int, height: int, allowed: list[str]) -> str: + if width <= 0 or height <= 0: + return "1:1" if "1:1" in allowed else allowed[0] + + target = width / height + + def ratio_value(value: str) -> float: + left, right = value.split(":", 1) + return float(left) / float(right) + + return min(allowed, key=lambda option: abs(ratio_value(option) - target)) + + +def compose_replicate_prompt(prompt_json: dict) -> str: + positive = str(prompt_json.get("positive prompt", "")).strip() + negative = str(prompt_json.get("negative prompt", "")).strip() + if not negative: + return positive + return f"{positive}\nAvoid: {negative}" + + +def extract_replicate_output_urls(output) -> list[str]: + if output is None: + return [] + if hasattr(output, "url"): + return [str(output.url)] + if isinstance(output, str): + return [output] + if isinstance(output, (list, tuple)): + urls = [] + for item in output: + urls.extend(extract_replicate_output_urls(item)) + return urls + if isinstance(output, dict): + urls = [] + for item in output.values(): + urls.extend(extract_replicate_output_urls(item)) + return urls + return [] + + +def load_image_from_url(url: str) -> Image.Image: + if url.startswith("data:"): + _, encoded = url.split(",", 1) + data = base64.b64decode(encoded) + else: + response = requests.get(url, timeout=REPLICATE_IMAGE_TIMEOUT) + response.raise_for_status() + data = response.content + + img = Image.open(io.BytesIO(data)) + if img.mode in {"RGBA", "LA"} or (img.mode == "P" and "transparency" in img.info): + return img.convert("RGBA") + return img.convert("RGB") + + +def save_generated_image(img: Image.Image, prompt_json: dict, object_name: str, idx: int, batch_count: int) -> str: + fname = get_next_available_filename(f"{sanitize_filename(object_name)}-{idx}") + metadata = dict(prompt_json, created=int(time.time()), batch_count=batch_count) + meta = PngImagePlugin.PngInfo() + meta.add_text("sd_json", json.dumps(metadata)) + img.save(fname, pnginfo=meta) + with open(fname + ".json", "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2) + return fname + + +def call_stable_diffusion(prompt_json: dict, user_input: str) -> list[str]: + """ + Generate 'n_imgs' images from diffusers, save to disk with embedded prompt JSON, + write sidecar .json, and return the file paths. + """ + if torch.backends.mps.is_available(): + device = "mps" + else: + device = "cpu" + + # Load pipeline + pipe = StableDiffusionPipeline.from_pretrained( + MODEL_PATH, + torch_dtype=torch.float32, + safety_checker=None, + local_files_only=True + ) + pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config) + pipe = pipe.to(device) + pipe.set_progress_bar_config(disable=True) + pipe.enable_attention_slicing() + + pos_prompt = prompt_json["positive prompt"] + neg_prompt = prompt_json["negative prompt"] + width, height = prompt_json["dimensions"] + steps = prompt_json.get("steps", 30) + guidance_scale = prompt_json.get("cfg_scale", 6.5) + batch_count = int(prompt_json.get("batch_count", 4)) # Standard: 4 + + images = pipe( + prompt=[pos_prompt] * batch_count, + negative_prompt=[neg_prompt] * batch_count, + width=width, + height=height, + num_inference_steps=steps, + guidance_scale=guidance_scale + ).images + + img_base = sanitize_filename(user_input) + out_files = [] + import time + for idx, img in enumerate(images, start=1): + filename = get_next_available_filename(f"{img_base}-{idx}") + + # Timestamp und batch_count speichern + prompt_json_with_time = dict(prompt_json) + prompt_json_with_time["created"] = int(time.time()) + prompt_json_with_time["batch_count"] = batch_count + + # Metadata und Sidecar wie gehabt + meta = PngImagePlugin.PngInfo() + meta.add_text("sd_json", json.dumps(prompt_json_with_time)) + img.save(filename, pnginfo=meta) + sidecar_path = filename + ".json" + with open(sidecar_path, "w", encoding="utf-8") as f: + json.dump(prompt_json_with_time, f, indent=2) + out_files.append(filename) + return out_files + + +# ------------- JSON “Fuzzy” Validator ------------- + +def validate_and_fix_json(raw: str) -> tuple[dict, str] | tuple[None, None]: + """ + 1) Try a strict json.loads(raw). + 2) If that fails, attempt to fix: + - Remove trailing commas (e.g. {"a":1,} -> {"a":1}) + - Replace single quotes with double quotes (naïve) + 3) If the “fixed” string parses, return (parsed_dict, fixed_string). + Otherwise, return (None, None). + """ + try: + parsed = json.loads(raw) + return parsed, raw + except Exception: + # Attempt quick fixes: + s = raw + # 1) Remove any trailing commas before } or ] + s = re.sub(r",\s*([}\]])", r"\1", s) + # 2) Replace single quotes around keys/values with double quotes (naïve) + s = re.sub(r"(?P
[:\s])'(?P[^']*)'", r'\1"\g"', s)
+        try:
+            parsed = json.loads(s)
+            return parsed, s
+        except Exception:
+            return None, None
+
+# ------------- PyWebview API -------------
+
+class Api:
+    def __init__(self):
+        self.window = None
+        self.last_object = ""
+        self.last_prompt_json = None
+        self._abort_sd = False
+        self._placeholders_ready_events = {}
+
+        # Keep track of file modification times
+        self._mtimes: dict[str, float] = {}
+        # Start folder watcher thread
+        watcher = threading.Thread(target=self._monitor_folder, daemon=True)
+        watcher.start()
+
+    def set_window(self, win):
+        self.window = win
+
+    def generate_prompt(self, object_name: str):
+        """
+        Runs only the LLM -> JSON steps (no Stable Diffusion).
+        """
+        self.last_object = object_name
+        self.last_prompt_json = None
+        threading.Thread(target=self._run_llm_to_json, args=(object_name,), daemon=True).start()
+        return {"status": "started_prompt"}
+
+    def _run_llm_to_json(self, object_name: str):
+        # 1) LLM streaming
+        llm_text = ""
+        llm_prompt = META_PROMPT.replace("{{OBJECT}}", object_name)
+        for chunk in stream_ollama(llm_prompt):
+            llm_text = chunk
+            self._js("window.on_llm_output", llm_text)
+        self._js("window.on_llm_done")
+
+        # 2) JSON streaming
+        json_text = ""
+        json_prompt = SECOND_PROMPT_TEMPLATE.replace("{PROMPT}", llm_text)
+        for chunk in get_json_from_llm(json_prompt):
+            json_text = chunk
+            self._js("window.on_json_output", json_text)
+        self._js("window.on_json_done")
+
+        # 3) Parse JSON
+        try:
+            prompt_dict = json.loads(json_text)
+        except Exception as e:
+            self._js("window.on_error", f"JSON parse error: {e}")
+            return
+
+        # 4) Augment with SD settings
+        prompt_dict["checkpoint"] = "RealismPlus/dreamshaper_8.safetensors"
+        prompt_dict["vae"] = "Automatic"
+        prompt_dict["sampler"] = "DPM++ 2M Karras"
+        prompt_dict["steps"] = 30
+        prompt_dict["cfg_scale"] = 6.5
+        prompt_dict["batch_count"] = 4
+
+        # 5) Send full JSON
+        full_json_str = json.dumps(prompt_dict, indent=2)
+        self._js("window.on_json_output", full_json_str)
+
+        # 6) Store for images
+        self.last_prompt_json = prompt_dict
+
+    def generate_images(self, json_str: str):
+        """
+        Called from JS when “Generate Images” is clicked.
+        1) Validate/fix JSON (fuzzy).
+        2) Ensure “positive prompt” is nonempty.
+        3) Auto fill missing keys (dimensions, checkpoint, etc.)
+        4) Kick off Stable Diffusion.
+        """
+        parsed, fixed = validate_and_fix_json(json_str)
+        if parsed is None:
+            self._js("window.on_error", "Cannot parse JSON (even after auto-fix).")
+            return {"status": "invalid_json"}
+
+        pos = parsed.get("positive prompt", "").strip()
+        if not pos:
+            self._js("window.on_error", "“positive prompt” must not be empty.")
+            return {"status": "empty_positive"}
+
+        # Auto-fill missing/malformed keys
+        dims = parsed.get("dimensions")
+        if not (
+            isinstance(dims, list)
+            and len(dims) == 2
+            and all(isinstance(x, int) for x in dims)
+        ):
+            parsed["dimensions"] = [768, 768]
+        parsed.setdefault("checkpoint", "RealismPlus/dreamshaper_8.safetensors")
+        parsed.setdefault("vae", "Automatic")
+        parsed.setdefault("sampler", "DPM++ 2M Karras")
+        parsed.setdefault("steps", 30)
+        parsed.setdefault("cfg_scale", 6.5)
+        parsed.setdefault("batch_count", 4)
+        
+        # If we “fixed” it, push corrected JSON back
+        full_json_str = json.dumps(parsed, indent=2)
+        if fixed is None or full_json_str != json_str:
+            self._js("window.on_json_output", full_json_str)
+
+        # Store and infer last_object if not yet set
+        self.last_prompt_json = parsed
+        if not self.last_object:
+            # Derive a base name from the positive prompt (before first comma)
+            cand = pos.split(",")[0]
+            base = re.sub(r"\s+", "_", cand.strip().lower())
+            base = re.sub(r"[^a-z0-9_]+", "", base)
+            self.last_object = base or "image"
+
+        # Launch SD thread – vorher Abort-Flag zurücksetzen
+        self._abort_sd = False
+        threading.Thread(target=self._run_sd_only, daemon=True).start()
+        return {"status": "started_images"}
+
+    def _get_image_generation_backend(self) -> str:
+        return normalize_image_backend(load_app_settings().get("image_generation_backend"))
+
+    def _get_replicate_image_model(self) -> str:
+        return normalize_replicate_image_model(load_app_settings().get("replicate_image_model"))
+
+    def _run_local_diffusion_images(self, batch_count: int) -> list[Image.Image]:
+        device = "mps" if torch.backends.mps.is_available() else "cpu"
+
+        pipe = StableDiffusionPipeline.from_pretrained(
+            MODEL_PATH, torch_dtype=torch.float32,
+            safety_checker=None, local_files_only=True
+        )
+        pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
+        pipe = pipe.to(device)
+        pipe.set_progress_bar_config(disable=True)
+        pipe.enable_attention_slicing()
+
+        pos = self.last_prompt_json["positive prompt"]
+        neg = self.last_prompt_json["negative prompt"]
+        w, h = self.last_prompt_json["dimensions"]
+        steps = self.last_prompt_json.get("steps", 30)
+        cfg = self.last_prompt_json.get("cfg_scale", 6.5)
+
+        def _check_abort(*args, **kwargs):
+            if self._abort_sd:
+                raise RuntimeError("User aborted")
+            return {}
+
+        outputs = pipe(
+            prompt=[pos] * batch_count,
+            negative_prompt=[neg] * batch_count,
+            width=w, height=h,
+            num_inference_steps=steps,
+            guidance_scale=cfg,
+            callback_on_step_end=_check_abort,
+        )
+        return outputs.images
+
+    def _build_replicate_image_input(self, model_id: str, num_outputs: int) -> dict:
+        model = REPLICATE_IMAGE_MODELS[model_id]
+        w, h = self.last_prompt_json["dimensions"]
+        ratio = nearest_aspect_ratio(w, h, model["aspect_ratios"])
+        prompt = compose_replicate_prompt(self.last_prompt_json)
+
+        if model_id == "black-forest-labs/flux-schnell":
+            steps = int(self.last_prompt_json.get("steps", 4))
+            return {
+                "prompt": prompt,
+                "aspect_ratio": ratio,
+                "num_outputs": max(1, min(int(num_outputs), 4)),
+                "num_inference_steps": max(1, min(steps, 4)),
+                "output_format": "png",
+                "go_fast": True,
+                "megapixels": "1",
+            }
+
+        if model_id == "google/imagen-4-fast":
+            return {
+                "prompt": prompt,
+                "aspect_ratio": ratio,
+                "output_format": "png",
+                "safety_filter_level": "block_only_high",
+            }
+
+        if model_id == "bytedance/seedream-4":
+            return {
+                "prompt": prompt,
+                "aspect_ratio": ratio,
+                "size": "2K",
+                "max_images": 1,
+                "enhance_prompt": False,
+                "sequential_image_generation": "disabled",
+            }
+
+        raise RuntimeError(f"Unsupported Replicate image model: {model_id}")
+
+    def _run_replicate_prediction(self, model_id: str, input_payload: dict):
+        token = self._get_replicate_api_token()
+        if not token:
+            raise RuntimeError("Replicate API token missing. Open settings and add it.")
+
+        client = replicate.Client(api_token=token)
+        client.poll_interval = REPLICATE_IMAGE_POLL_INTERVAL
+        prediction = client.models.predictions.create(
+            model=model_id,
+            input=input_payload,
+            wait=False,
+        )
+        print(f"[Replicate Image] Prediction started: {prediction.id} ({prediction.status})")
+
+        deadline = time.monotonic() + REPLICATE_IMAGE_TIMEOUT
+        last_status = prediction.status
+        while prediction.status not in {"succeeded", "failed", "canceled"}:
+            if self._abort_sd:
+                try:
+                    prediction.cancel()
+                except Exception:
+                    pass
+                raise RuntimeError("User aborted")
+
+            if time.monotonic() >= deadline:
+                raise TimeoutError(
+                    f"Timed out waiting for Replicate image prediction {prediction.id} "
+                    f"after {REPLICATE_IMAGE_TIMEOUT // 60} minutes."
+                )
+
+            time.sleep(client.poll_interval)
+            prediction.reload()
+            if prediction.status != last_status:
+                print(f"[Replicate Image] {prediction.id}: {prediction.status}")
+                last_status = prediction.status
+
+        if prediction.status != "succeeded":
+            detail = prediction.error or prediction.logs or f"status={prediction.status}"
+            raise RuntimeError(f"Replicate image prediction {prediction.id} failed: {detail}")
+
+        return prediction.output
+
+    def _run_replicate_images(self, batch_count: int) -> list[Image.Image]:
+        model_id = self._get_replicate_image_model()
+        images: list[Image.Image] = []
+
+        if model_id == "black-forest-labs/flux-schnell":
+            remaining = batch_count
+            while remaining > 0:
+                if self._abort_sd:
+                    raise RuntimeError("User aborted")
+                chunk = min(remaining, 4)
+                output = self._run_replicate_prediction(
+                    model_id,
+                    self._build_replicate_image_input(model_id, chunk),
+                )
+                urls = extract_replicate_output_urls(output)
+                if len(urls) < chunk:
+                    raise RuntimeError(f"Replicate returned {len(urls)} image(s), expected {chunk}.")
+                images.extend(load_image_from_url(url) for url in urls[:chunk])
+                remaining -= chunk
+            return images
+
+        for _ in range(batch_count):
+            if self._abort_sd:
+                raise RuntimeError("User aborted")
+            output = self._run_replicate_prediction(
+                model_id,
+                self._build_replicate_image_input(model_id, 1),
+            )
+            urls = extract_replicate_output_urls(output)
+            if not urls:
+                raise RuntimeError("Replicate did not return an image URL.")
+            images.append(load_image_from_url(urls[0]))
+
+        return images
+
+    def _run_sd_only(self):
+        if not self.last_prompt_json:
+            self._js("window.on_error", "No valid JSON prompt available.")
+            return
+
+        batch_id    = random.randint(1_000_000, 9_999_999)
+        self._last_batch_id = batch_id
+        batch_count = int(self.last_prompt_json.get("batch_count", 4))
+
+        # 1) Bail out if user already hit “Stop”
+        if self._abort_sd:
+            return
+
+        # 2) Show placeholders in the UI
+        self._js("window.show_placeholders", batch_count, batch_id)
+        event = threading.Event()
+        self._placeholders_ready_events[batch_id] = event
+
+        # 3) Wait until JS signals placeholders are in place
+        while not event.is_set():
+            time.sleep(0.02)
+        if self._abort_sd:
+            self._js("window.remove_placeholders", batch_id)
+            return
+
+        # 4) Set up and run the selected image generator.
+        try:
+            if self._get_image_generation_backend() == IMAGE_BACKEND_REPLICATE:
+                images = self._run_replicate_images(batch_count)
+            else:
+                images = self._run_local_diffusion_images(batch_count)
+
+        except RuntimeError as e:
+            # user‐initiated abort
+            if str(e) == "User aborted":
+                self._js("window.on_generation_aborted")
+                self._js("window.remove_placeholders", batch_id)
+                return
+            # or some other error
+            self._js("window.remove_placeholders", batch_id)
+            self._js("window.on_error", f"Image generation error: {e}")
+            return
+        except Exception as e:
+            self._js("window.remove_placeholders", batch_id)
+            self._js("window.on_error", f"Image generation error: {e}")
+            return
+
+        # 5) Replace placeholders one by one
+        for idx, img in enumerate(images):
+            if self._abort_sd:
+                self._js("window.remove_placeholders", batch_id)
+                return
+
+            fname = save_generated_image(
+                img,
+                self.last_prompt_json,
+                self.last_object,
+                idx + 1,
+                batch_count,
+            )
+
+            rel = os.path.relpath(fname, WEB_DIR)
+            self._js("window.replace_placeholder", idx, rel, batch_id)
+
+        # 6) Tear down
+        self._js("window.remove_placeholders", batch_id)
+        self._js("window.on_image_gen_done")
+        del self._placeholders_ready_events[batch_id]
+        
+    def stop_generation(self):
+        self._abort_sd = True
+        # Do NOT remove placeholders here—wait until the abort is processed
+        return {"status": "aborting"}
+
+    def placeholders_ready(self, batch_id):
+        if batch_id in self._placeholders_ready_events:
+            self._placeholders_ready_events[batch_id].set()
+        return {"status": "acknowledged"}
+
+    def get_settings(self):
+        settings = load_app_settings()
+        settings_token = str(settings.get("replicate_api_token", "")).strip()
+        env_token = os.environ.get("REPLICATE_API_TOKEN", "").strip()
+        source = "settings" if settings_token else "environment" if env_token else ""
+        return {
+            "has_replicate_api_token": bool(settings_token or env_token),
+            "replicate_api_token_source": source,
+            "replicate_api_token": "",
+            "image_generation_backend": normalize_image_backend(settings.get("image_generation_backend")),
+            "replicate_image_model": normalize_replicate_image_model(settings.get("replicate_image_model")),
+            "replicate_image_models": [
+                {
+                    "id": model_id,
+                    "label": model["label"],
+                    "price": model["price"],
+                }
+                for model_id, model in REPLICATE_IMAGE_MODELS.items()
+            ],
+        }
+
+    def save_settings(self, settings: dict):
+        current = load_app_settings()
+        token = str(settings.get("replicate_api_token", "")).strip()
+        if token:
+            current["replicate_api_token"] = token
+
+        current["image_generation_backend"] = normalize_image_backend(
+            settings.get("image_generation_backend")
+        )
+        current["replicate_image_model"] = normalize_replicate_image_model(
+            settings.get("replicate_image_model")
+        )
+        save_app_settings(current)
+        return {
+            "status": "saved",
+            "has_replicate_api_token": bool(self._get_replicate_api_token()),
+            "image_generation_backend": current["image_generation_backend"],
+            "replicate_image_model": current["replicate_image_model"],
+        }
+
+    def clear_replicate_api_token(self):
+        current = load_app_settings()
+        current.pop("replicate_api_token", None)
+        save_app_settings(current)
+        env_token = os.environ.get("REPLICATE_API_TOKEN", "").strip()
+        return {
+            "status": "cleared",
+            "has_replicate_api_token": bool(env_token),
+            "replicate_api_token_source": "environment" if env_token else "",
+            "image_generation_backend": normalize_image_backend(current.get("image_generation_backend")),
+            "replicate_image_model": normalize_replicate_image_model(current.get("replicate_image_model")),
+        }
+
+    def _get_replicate_api_token(self):
+        settings_token = str(load_app_settings().get("replicate_api_token", "")).strip()
+        return settings_token or os.environ.get("REPLICATE_API_TOKEN", "").strip()
+
+    def _subprocess_env(self):
+        env = os.environ.copy()
+        token = self._get_replicate_api_token()
+        if token:
+            env["REPLICATE_API_TOKEN"] = token
+        return env
+
+
+    def _open_blender_with_scene(self, glb_path, hdri_path):
+        # Kopiere ggf. vorbereitete Blender-Template-Datei, z.B. "scene_template.blend"
+        blender_path = "/Applications/Blender.app/Contents/MacOS/Blender"
+        # Template: In Blender musst du ein Skript haben, das das .glb und das hdri_path lädt
+        # oder als Startscript mitgibst!
+        cmd = [
+            "/Applications/Blender.app/Contents/MacOS/Blender",
+            "--python", "scene_setup.py",
+            "--", glb_path, hdri_path or ""
+        ]
+        try:
+            subprocess.Popen(cmd)
+            return {"status": "opened_blender"}
+        except Exception as e:
+            self._js("window.on_error", f"Failed to open in Blender: {e}")
+            return {"status": "blender_error"}
+
+
+    def edit_external(self, filepath: str):
+        # filepath kommt von 2D als "output/foo.png" oder von 3D als "output/foo.png"
+        ext = os.path.splitext(filepath)[1].lower()
+        full_path = os.path.join(BASE_DIR, "web", filepath)
+        # Ist es ein Bild oder das PNG eines 3D-Modells?
+        if ext == ".png":
+            # Prüfe, ob ein GLB daneben liegt
+            fbase = os.path.splitext(full_path)[0]
+            glb_path = fbase + ".glb"
+            # Prüfe, ob auch eine HDRI da ist (Sidecar JSON auslesen)
+            hdri_path = None
+            sidecar = full_path + ".json"
+            if os.path.isfile(sidecar):
+                with open(sidecar, "r", encoding="utf-8") as f:
+                    meta = json.load(f)
+                    if "hdri_seamless" in meta:
+                        hdri_path = os.path.join(os.path.dirname(full_path), meta["hdri_seamless"])
+            # Wenn .glb existiert → ist 3D Model: Blender öffnen!
+            if os.path.isfile(glb_path):
+                return self._open_blender_with_scene(glb_path, hdri_path)
+            # Sonst wie bisher (Photoshop)
+            try:
+                subprocess.Popen(["open", "-a", "Adobe Photoshop 2024", full_path])
+            except Exception as e:
+                self._js("window.on_error", f"Failed to open in Photoshop: {e}")
+            return {"status": "opened_external"}
+
+
+            # ------------- Generate 3D Model -------------
+    def generate_3d_model(self, filepath: str):
+        # filepath kommt aus JS als "output/flower-1.png"
+        full_path = os.path.join(BASE_DIR, "web", filepath)
+        if not os.path.isfile(full_path):
+            self._js("window.on_error", f"Image not found: {full_path}")
+            return {"status": "missing_file"}
+
+        threading.Thread(target=self._run_generate_3d, args=(full_path,), daemon=True).start()
+        return {"status": "started_3d"}
+
+    def _run_generate_3d(self, img_path: str):
+        """
+        Execute the user‐provided script `image_to_3d.py `, wait for completion,
+        then, if a .glb was produced, call JS callback window.on_3d_generated(glb_path).
+        Otherwise, call window.on_error(…).
+        """
+        try:
+            if not self._get_replicate_api_token():
+                self._js(
+                    "window.on_error",
+                    "Replicate API token missing. Open settings and add it.",
+                )
+                return
+
+            # Construct the command: sys.executable ensures we run with same Python interpreter
+            script_path = os.path.join(BASE_DIR, "image_to_3d.py")
+            cmd = [sys.executable, script_path, img_path]
+            base, _ = os.path.splitext(img_path)
+            glb_path = base + ".glb"
+            previous_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
+            result = subprocess.run(
+                cmd,
+                capture_output=True,
+                text=True,
+                env=self._subprocess_env(),
+            )
+
+            # Check for success: sidecar or actual .glb in same folder
+            current_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
+
+            if current_mtime is not None and current_mtime != previous_mtime:
+                # Notify JavaScript
+                self._js("window.on_3d_generated", glb_path)
+            else:
+                # If the script failed or no .glb was produced, send an error
+                err_msg = (
+                    result.stderr.strip()
+                    or result.stdout.strip()
+                    or "3D conversion did not produce .glb"
+                )
+                self._js("window.on_error", f"3D Model failed: {err_msg}")
+        except Exception as e:
+            self._js("window.on_error", f"3D conversion exception: {e}")
+
+
+
+
+
+            # ------------- NEW: Generate 3D Model PLUS equirectangular map -------------
+
+    def generate_3d_and_hdri(self, filepath: str):
+        """
+        Called when user wants both 3D model and equirectangular HDRI for an image.
+        Kicks off both processes, waits for both, and (later) calls the next step.
+        """
+        full_path = os.path.join(BASE_DIR, "web", filepath)
+        if not os.path.isfile(full_path):
+            self._js("window.on_error", f"Image not found: {full_path}")
+            return {"status": "missing_file"}
+        # Start thread for orchestration
+        threading.Thread(target=self._run_3d_and_hdri, args=(full_path,), daemon=True).start()
+        return {"status": "started_3d_and_hdri"}
+
+    def _run_3d_and_hdri(self, img_path: str):
+        # 1. Get object info (from PNG metadata or sidecar)
+        obj_name = self._extract_object_from_json(img_path)
+        print(f"[3D+HDRI] Object name from JSON: {obj_name}")
+        if not obj_name:
+            self._js("window.on_error", "Could not determine object name for HDRI prompt.")
+            return
+
+        # 2. Generate short background prompt using Mistral (Ollama) with streaming debug
+        user_prompt = f"""Suggest a short, vivid background description for a 3D scene featuring {obj_name}. \
+    The background should be immersive and plausible, suitable as an equirectangular HDRI for 3D rendering. \
+    Do NOT mention the object itself. Describe the environment in a concise way, e.g.: "lush wheat field under a blue sky", "bustling medieval town square at dawn", "mysterious alien jungle with glowing plants", etc. Respond only with the description, no extra text. Only describe the appearance of the environment, no other attributes than what is to see. \
+    Keep it short and precise, use 2 to 5 words max! No punctuation, just commas if needed. All lowercase. As short as possible!"""
+        print(f"[HDRI LLM] Sending prompt to LLM:\n{user_prompt}\n")
+
+        pano_prompt = None
+        for chunk in stream_ollama(user_prompt):
+            print(f"[HDRI LLM] {chunk!r}")
+            pano_prompt = chunk.strip()
+        if not pano_prompt:
+            self._js("window.on_error", "Could not generate panorama prompt.")
+            return
+
+        # 3. Prepend required prefix and print
+        pano_prompt_full = "hdri panorama view, equirectangular, 3d rendering of " + pano_prompt
+        prompt_with_colons = f":{pano_prompt_full}:"
+        print(f"[HDRI PROMPT] Final panorama prompt:\n{prompt_with_colons}\n")
+
+        # 4. Prepare output path in OUTPUT_DIR
+        out_base = os.path.splitext(os.path.basename(img_path))[0]
+        hdri_filename = f"{out_base}_hdri_seamless.png"
+        hdri_output_path = os.path.join(OUTPUT_DIR, hdri_filename)
+        print(f"[HDRI PATH] Will save to: {hdri_output_path}")
+
+        # 5. Run both tasks concurrently
+        import concurrent.futures
+        with concurrent.futures.ThreadPoolExecutor() as executor:
+            fut_3d = executor.submit(self._run_generate_3d_and_return, img_path)
+            fut_hdri = executor.submit(self._run_equirect_map, prompt_with_colons, hdri_output_path)
+            done, _ = concurrent.futures.wait([fut_3d, fut_hdri], return_when=concurrent.futures.ALL_COMPLETED)
+
+        # 6. Check results
+        err3d = fut_3d.exception() if fut_3d.done() else "3D model task did not finish"
+        errhdri = fut_hdri.exception() if fut_hdri.done() else "HDRI task did not finish"
+
+        if err3d or errhdri:
+            msg = ""
+            if err3d:
+                msg += f"3D model error: {err3d}\n"
+            if errhdri:
+                msg += f"HDRI error: {errhdri}\n"
+            self._js("window.on_error", msg.strip())
+            return
+
+        glb_path = fut_3d.result()
+        hdri_path = fut_hdri.result()
+
+        # 7. Write HDRI path into the original image's JSON sidecar (absolute, relative, or both)
+        sidecar_path = img_path + ".json"
+        try:
+            if os.path.isfile(sidecar_path):
+                with open(sidecar_path, "r", encoding="utf-8") as f:
+                    meta = json.load(f)
+            else:
+                meta = {}
+
+            meta["hdri_seamless"] = os.path.relpath(hdri_path, OUTPUT_DIR)  # or just hdri_filename
+            with open(sidecar_path, "w", encoding="utf-8") as f:
+                json.dump(meta, f, indent=2)
+            print(f"[HDRI JSON] Wrote HDRI path to {sidecar_path}: {meta['hdri_seamless']}")
+        except Exception as e:
+            print(f"[HDRI JSON] ERROR writing HDRI path: {e}")
+
+        self._on_3d_and_hdri_ready(glb_path, hdri_path)
+
+    def _extract_object_from_json(self, img_path: str) -> str:
+        """
+        Helper: Tries to read the object/positive prompt from sidecar or PNG metadata.
+        """
+        try:
+            sidecar = img_path + ".json"
+            if os.path.isfile(sidecar):
+                with open(sidecar, "r", encoding="utf-8") as f:
+                    meta = json.load(f)
+                    return meta.get("positive prompt", "").split(",")[0]
+            else:
+                # Try embedded PNG metadata as fallback
+                img = Image.open(img_path)
+                info = img.info
+                if "sd_json" in info:
+                    d = json.loads(info["sd_json"])
+                    return d.get("positive prompt", "").split(",")[0]
+        except Exception:
+            pass
+        return None
+
+    def _get_panorama_prompt(self, obj_name: str) -> str:
+        """
+        Calls Mistral (Ollama) to generate a short background panorama prompt.
+        """
+        user_prompt = f"""Suggest a short, vivid background description for a 3D scene featuring {obj_name}. \
+The background should be immersive and plausible, suitable as an equirectangular HDRI for 3D rendering. \
+Do NOT mention the object itself. Describe the environment in a concise way, e.g.: "a lush wheat field under a blue sky", "a bustling medieval town square at dawn", "a mysterious alien jungle with glowing plants", etc. Respond only with the description, no extra text."""
+
+        try:
+            for response in stream_ollama(user_prompt):
+                last = response.strip()
+            return last
+        except Exception:
+            return None
+
+    def _run_generate_3d_and_return(self, img_path: str):
+        """
+        Same as _run_generate_3d, but returns the GLB path on success (or raises).
+        """
+        if not self._get_replicate_api_token():
+            raise RuntimeError("Replicate API token missing. Open settings and add it.")
+
+        script_path = os.path.join(BASE_DIR, "image_to_3d.py")
+        cmd = [sys.executable, script_path, img_path]
+        base, _ = os.path.splitext(img_path)
+        glb_path = base + ".glb"
+        previous_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
+        result = subprocess.run(
+            cmd,
+            capture_output=True,
+            text=True,
+            env=self._subprocess_env(),
+        )
+        current_mtime = os.path.getmtime(glb_path) if os.path.isfile(glb_path) else None
+        if current_mtime is not None and current_mtime != previous_mtime:
+            self._js("window.on_3d_generated", glb_path)
+            return glb_path
+        else:
+            err_msg = (
+                result.stderr.strip()
+                or result.stdout.strip()
+                or "3D conversion did not produce .glb"
+            )
+            raise RuntimeError(err_msg)
+
+    def _run_equirect_map(self, prompt: str, output_path: str):
+        """
+        Calls your equi map generator script.
+        Logs command and full output for debugging.
+        """
+        script_path = os.path.join(BASE_DIR, "generate_equirect.py")
+        cmd = [sys.executable, script_path, "--prompt", prompt, "--output", output_path]
+        print(f"[HDRI CMD] {' '.join(cmd)}")
+        result = subprocess.run(cmd, capture_output=True, text=True)
+        print(f"[HDRI STDOUT]\n{result.stdout}")
+        print(f"[HDRI STDERR]\n{result.stderr}")
+        if result.returncode == 0 and os.path.isfile(output_path):
+            print(f"[HDRI OK] Created: {output_path}")
+            return output_path
+        else:
+            raise RuntimeError(result.stderr.strip() or f"HDRI generation failed for {output_path}")
+
+    def _on_3d_and_hdri_ready(self, glb_path: str, hdri_path: str):
+        """
+        Dummy function to handle successful completion of both tasks.
+        For now: just print/log, later: implement what you need (e.g. combine assets, notify UI, etc).
+        """
+        print(f"3D model ready: {glb_path}")
+        print(f"HDRI ready: {hdri_path}")
+        # Example: self._js("window.on_3d_and_hdri_ready", glb_path, hdri_path)
+
+
+
+
+
+
+
+
+
+
+
+
+
+    # ------------- END of Generate 3D Model & Equi methods -------------
+    def get_initial_images(self):
+        entries = []
+        for fname in os.listdir(OUTPUT_DIR):
+            if not fname.lower().endswith(".png"):
+                continue
+
+            fpath = os.path.join(OUTPUT_DIR, fname)
+            # Prüfe, ob Sidecar existiert & valides JSON ist
+            sidecar = fpath + ".json"
+            if not os.path.isfile(sidecar):
+                continue
+            try:
+                with open(sidecar, "r", encoding="utf-8") as f:
+                    meta = json.load(f)
+                    # Optional: Check ob wirklich ein Prompt drin ist
+                    if not meta.get("positive prompt"):
+                        continue
+                    created = int(meta.get("created", 0))
+            except Exception:
+                continue
+
+            rel = os.path.relpath(os.path.join(OUTPUT_DIR, fname), WEB_DIR)
+            entries.append({
+                "filepath": rel,
+                "created": created
+            })
+
+        return sorted(entries, key=lambda e: e["created"], reverse=True)
+
+    def get_3d_models(self):
+        entries = []
+        for fname in os.listdir(OUTPUT_DIR):
+            if not fname.lower().endswith(".png"):
+                continue
+            png_path = os.path.join(OUTPUT_DIR, fname)
+            fbase = os.path.splitext(fname)[0]
+            glb_path = os.path.join(OUTPUT_DIR, fbase + ".glb")
+            if os.path.isfile(glb_path):
+                # Lade "created" aus Sidecar-JSON
+                created = 0
+                sidecar = png_path + ".json"
+                if os.path.isfile(sidecar):
+                    try:
+                        with open(sidecar, "r", encoding="utf-8") as f:
+                            meta = json.load(f)
+                            created = int(meta.get("created", 0))
+                    except Exception:
+                        pass
+                rel_img = os.path.relpath(png_path, WEB_DIR)
+                rel_glb = os.path.relpath(glb_path, WEB_DIR)
+                entries.append({
+                    "img": rel_img,
+                    "glb": rel_glb,
+                    "created": created
+                })
+        # Sortiere nach created DESC
+        models = sorted(entries, key=lambda e: e["created"], reverse=True)
+        # Entferne "created" vor dem Rückgeben (falls Frontend das nicht braucht)
+        for m in models:
+            if "created" in m:
+                del m["created"]
+        return models
+        
+    def get_image_json(self, filepath: str):
+        # filepath kommt aus JS als "output/flower-1.png"
+        full_path = os.path.join(BASE_DIR, "web", filepath)
+        try:
+            # Always prefer the sidecar if available (contains most up-to-date data)
+            sidecar = full_path + ".json"
+            if os.path.isfile(sidecar):
+                with open(sidecar, "r", encoding="utf-8") as f:
+                    d = json.load(f)
+                    if "created" in d:
+                        del d["created"]
+                    return json.dumps(d, indent=2)
+            # Fallback: try embedded metadata in PNG
+            img = Image.open(full_path)
+            info = img.info
+            if "sd_json" in info:
+                d = json.loads(info["sd_json"])
+                if "created" in d:
+                    del d["created"]
+                return json.dumps(d, indent=2)
+            else:
+                return json.dumps({"error": "No embedded generation data found."})
+        except Exception as e:
+            return json.dumps({"error": f"Failed to read metadata: {e}"})
+
+    def _monitor_folder(self):
+        """
+        Every 2 seconds, scan OUTPUT_DIR for PNGs. If any file is new or its mtime changed,
+        send window.on_image_updated(filepath, dataUri) to front end.
+        """
+        while True:
+            time.sleep(2)
+            for fname in os.listdir(OUTPUT_DIR):
+                if not fname.lower().endswith(".png"):
+                    continue
+                fpath = os.path.join(OUTPUT_DIR, fname)
+                try:
+                    mtime = os.path.getmtime(fpath)
+                except FileNotFoundError:
+                    continue
+                previous = self._mtimes.get(fpath)
+                if previous is None or mtime > previous:
+                    # New or modified
+                    self._mtimes[fpath] = mtime
+                    try:
+                        data_uri = image_to_base64(fpath)
+                        rel = os.path.relpath(fpath, WEB_DIR)  # → "output/flower-2.png"
+                        self._js("window.on_image_updated", rel, data_uri)
+                    except Exception:
+                        pass
+
+    def _js(self, fn: str, *args):
+        import json
+        args_json = json.dumps(list(args))
+        code = f"{fn}.apply(null, {args_json})"
+        self.window.evaluate_js(code)
+
+
+# ------------- Launch PyWebview -------------
+
+if __name__ == "__main__":
+    api = Api()
+    window = webview.create_window(
+        "SD 3D Model Image Gen",
+        "web/index.html",
+        js_api=api,
+        width=900,
+        height=1000,
+        min_size=(650, 500),
+        resizable=True
+    )
+    api.set_window(window)
+    webview.start(debug=True)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c1d93db
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,12 @@
+numpy<2
+
+accelerate==0.31.0
+diffusers==0.27.2
+huggingface-hub==0.23.4
+Pillow==12.0.0
+pywebview==5.4
+replicate==1.0.7
+requests==2.32.5
+safetensors==0.7.0
+torch==2.9.1
+transformers==4.41.2
diff --git a/run.sh b/run.sh
new file mode 100755
index 0000000..10046c5
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+export HF_HOME="${HF_HOME:-$PWD/.hf_cache}"
+export HUGGINGFACE_HUB_CACHE="${HUGGINGFACE_HUB_CACHE:-$HF_HOME/hub}"
+mkdir -p "$HUGGINGFACE_HUB_CACHE"
+
+if [ -n "${PYTHON:-}" ]; then
+  python_bin="$PYTHON"
+elif command -v python3.11 >/dev/null 2>&1; then
+  python_bin="python3.11"
+elif command -v python3 >/dev/null 2>&1; then
+  python_bin="python3"
+else
+  echo "Could not find python3.11 or python3. Install Python 3.11, then run this script again." >&2
+  exit 1
+fi
+
+if [ ! -d ".venv" ]; then
+  "$python_bin" -m venv .venv
+fi
+
+source .venv/bin/activate
+
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+
+exec python main.py
diff --git a/scene_setup.py b/scene_setup.py
new file mode 100644
index 0000000..aa60b80
--- /dev/null
+++ b/scene_setup.py
@@ -0,0 +1,77 @@
+import bpy
+import sys
+import os
+
+argv = sys.argv
+argv = argv[argv.index("--") + 1:] if "--" in argv else []
+
+glb_path = argv[0] if len(argv) > 0 else None
+hdri_path = argv[1] if len(argv) > 1 else None
+
+#bpy.ops.wm.read_factory_settings(use_empty=True)
+if "Cube" in bpy.data.objects:
+    bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
+
+# GLB importieren
+if glb_path and os.path.isfile(glb_path):
+    bpy.ops.import_scene.gltf(filepath=glb_path)
+else:
+    print("GLB file missing:", glb_path)
+
+# HDRI oder Sonne
+hdri_loaded = False
+if hdri_path and os.path.isfile(hdri_path):
+    try:
+        world = bpy.data.worlds.new("World") if not bpy.data.worlds else bpy.data.worlds[0]
+        bpy.context.scene.world = world
+        world.use_nodes = True
+        ntree = world.node_tree
+        nodes = ntree.nodes
+        for node in nodes: nodes.remove(node)
+        node_bg = nodes.new(type='ShaderNodeBackground')
+        node_env = nodes.new(type='ShaderNodeTexEnvironment')
+        node_out = nodes.new(type='ShaderNodeOutputWorld')
+        node_env.image = bpy.data.images.load(hdri_path)
+        node_env.location = (-300, 0)
+        node_bg.location = (0, 0)
+        node_out.location = (300, 0)
+        ntree.links.new(node_env.outputs['Color'], node_bg.inputs['Color'])
+        ntree.links.new(node_bg.outputs['Background'], node_out.inputs['Surface'])
+        hdri_loaded = True
+    except Exception as e:
+        print("Failed to load HDRI:", e)
+        hdri_loaded = False
+
+if not hdri_loaded:
+    # Schöne Sonnenlampe (leicht schräg von oben)
+    light_data = bpy.data.lights.new(name="Sun", type='SUN')
+    light_data.energy = 4.5
+    light = bpy.data.objects.new(name="Sun", object_data=light_data)
+    bpy.context.collection.objects.link(light)
+    light.location = (4, 10, 10)
+    light.rotation_euler = (0.8, 0.3, 0.1)  # leicht schräg
+    # Optionale Fill-Light/Soft-Ambient
+    light_data2 = bpy.data.lights.new(name="Fill", type='SUN')
+    light_data2.energy = 1.1
+    light2 = bpy.data.objects.new(name="Fill", object_data=light_data2)
+    bpy.context.collection.objects.link(light2)
+    light2.location = (-8, -6, 4)
+    light2.rotation_euler = (1.4, -0.8, -0.2)
+
+for window in bpy.context.window_manager.windows:
+    for area in window.screen.areas:
+        if area.type == 'VIEW_3D':
+            for space in area.spaces:
+                if space.type == 'VIEW_3D':
+                    space.shading.type = 'RENDERED'
+                    
+for obj in bpy.data.objects:
+    # Entparenten, falls Parent ein Empty namens "world" ist
+    if obj.parent and obj.parent.name == "world":
+        obj.parent = None
+
+if "world" in bpy.data.objects and bpy.data.objects["world"].type == "EMPTY":
+    bpy.data.objects.remove(bpy.data.objects["world"], do_unlink=True)
+
+if "Cube" in bpy.data.objects:
+    bpy.data.objects.remove(bpy.data.objects["Cube"], do_unlink=True)
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..3dfa98f
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,2375 @@
+
+
+
+    3D-Modell Generator
+    
+
+        
+        
+        
+        
+
+        
+
+
+
+        
+
+        
+
+
+
+
+    
+
+

3D-Model Generator

+ +
+ + +
+
+ + + ⏳ Generating... +
+
+ + +
+ +

+        
+ + +
+ +
+ +
+
+ +
+
+
+ + + +
+ + + + +
+
Edit Externally
+
Use Generation Data
+
Generate 3D Model
+
Generate 3D Model + HDRI
+
+ + +
+
+ + + Full Image + +
+
+ + +
+
+ + +
+ +
+
+ + + +