# Build Your Own Local AI Studio — a complete guide

> **What this is:** a full, battle-tested playbook for turning a cheap second-hand PC (~$500, 8 GB GPU) into a private AI production studio — image, video, 3D, image-editing, relighting, music, speech, transcription, background removal — all running locally, controlled from your main computer or phone through one web app. No cloud, no subscriptions, no per-image fees.
>
> It was built and debugged end-to-end on real hardware. Every step, model link, and gotcha here is something that was actually done — including the mistakes and their fixes.

---

## 📌 HOW TO USE THIS FILE (read me first)

**If you are a person reading this:** upload this whole file to Claude (claude.ai) or open it with Claude Code, and say:

> *"Help me build the local AI studio described in this file. Guide me one step at a time, check my work before moving on, and adapt to my hardware and skill level."*

Claude will then walk you through it interactively — running commands for you where it can, explaining each step, and troubleshooting when things go sideways.

**If you are Claude reading this:** you are the build guide. The person who uploaded this wants to reproduce the studio described below. Work through it **phase by phase**, confirm each phase works before advancing, and lean on the **Troubleshooting** section — every problem there was hit for real. Prefer getting a *minimal working studio* running first (Phases 1–6), then add capabilities (Phase 7). Don't dump all 20 models on them at once. Ask about their hardware and OS early. The full working code for the studio web app is in the **Appendices** at the end — use it verbatim (after they fill in the small CONFIG block).

**Two machines, two roles:**
- **The "PC"** = the cheap Windows box with the NVIDIA GPU. It does the heavy AI compute. Runs headless (no monitor needed once set up).
- **The "Mac" / main computer** = whatever you use daily. It serves the little web app and talks to the PC over an encrypted tunnel. Can be a Mac, Linux box, or another Windows PC — anything that runs Python and SSH.

> **You do not need any API keys, tokens, or paid accounts to follow this guide.** Every model below is downloaded from a free, public, non-gated source.

---

## 🎯 PART 0 — What you're building

```
   Your phone / laptop            The GPU PC (headless, in a corner)
  ┌───────────────────┐          ┌──────────────────────────────┐
  │  Web browser      │          │  ComfyUI  (the AI engine)     │
  │  → the Studio app │◀────────▶│  25+ models · one at a time   │
  └───────────────────┘  tunnel  │  Whisper · Piper · Kokoro     │
        ▲                (SSH)    │  boots itself · self-heals    │
        │                        └──────────────────────────────┘
  ┌───────────────────┐
  │  Main computer    │  ← runs the tiny Python web server that
  │  (Mac/Linux/PC)   │    drives the PC and holds the tunnel
  └───────────────────┘
     all linked by Tailscale (a free, zero-config private network)
```

**The design principle:** *heavy compute lives where the GPU is; everything else — the interface, orchestration, file handling — runs on whatever device you're holding.* The web app is deliberately a **single Python file + single HTML file** with no frameworks, so it's easy to understand and fix.

**Rough time & cost:**
- Money: ~$500 for the PC, optional ~$40 for a spare SSD + WiFi/dummy-plug bits.
- Time: an afternoon to a weekend depending on your comfort level and download speeds (the models are ~50–80 GB total if you grab everything).

---

## 🛒 PART 1 — Buy the right hardware

You're shopping the **used market** (Facebook Marketplace, local classifieds, eBay). The goal: a gaming PC someone's offloading, with a GPU that can actually do AI.

### The one thing that matters most: the GPU

AI image/video models need an **NVIDIA GPU with Tensor Cores and at least 8 GB of VRAM.** This is non-negotiable — it's the single spec that makes or breaks the whole project.

| GPU | Verdict | Notes |
|---|---|---|
| **RTX 3060 12 GB** | 🥇 Best value | 12 GB VRAM is a big deal — skips a lot of the 8 GB pain below |
| **RTX 2070 / 2070 Super 8 GB** | ✅ Great (this build used a 2070 Super) | Proven; everything in this guide runs on it |
| **RTX 2060 Super 8 GB** | ✅ Good | Slightly slower, same 8 GB |
| **RTX 3070 / 3080** | ✅ Excellent if in budget | Faster; 3080 has 10 GB |
| **RTX 4060 / 4060 Ti 16 GB** | ✅ Modern, efficient | The 16 GB Ti is a sweet spot if you can find it cheap |
| GTX 1660 / 1650 | ⚠️ Weak | No Tensor Cores, only 6 GB — will struggle. Avoid if possible. |
| GTX 1060 / 1070 / 1080 | ❌ No | Too old, no Tensor Cores |
| Quadro P-series, Tesla K80/M40 | ❌ No | Old architecture, driver pain, no real speed |
| AMD Radeon (any) | ❌ No | This whole stack is CUDA (NVIDIA-only) |

**Rule of thumb:** any **RTX (not GTX)** card with **8 GB or more** is good. More VRAM = fewer headaches. If you can stretch to **12 GB+**, do it — a lot of the "8 GB discipline" tricks in this guide become unnecessary.

### The rest of the PC (much more forgiving)

- **CPU:** any modern quad-core or better. An Intel i5/i7 8th-gen or newer, or Ryzen equivalent, is plenty. (This build: i7-9700K.)
- **RAM:** **16 GB minimum, 32 GB strongly recommended.** DDR4 is dirt cheap used (~$30 for 16 GB). More system RAM dramatically eases the 8 GB-VRAM model-swapping — the PC can keep big models cached instead of re-reading them from disk. **This build upgraded to 32 GB and it made a real difference.**
- **Power supply:** **550 W or more** for the GPUs above. Check the wattage sticker on the PSU. (This build: 650 W.)
- **Storage:** you want **an SSD for Windows** (240 GB is enough) plus room for models and outputs. Ideal layout:
  - **C:** = Windows + apps (SSD)
  - a **big drive** (HDD is fine, 1–2 TB) for AI outputs — you'll generate a lot
  - a **cheap extra SSD** (~$30 for 240–500 GB) dedicated to models is a fantastic add — models load faster from SSD, and it keeps your C: drive clean. This build added a spare 240 GB SATA SSD as the "models" drive.
- **Motherboard/case:** whatever it comes with. Bonus if the board has free M.2 NVMe slots for future SSD upgrades.

### What to check BEFORE you pay

1. **See it run.** Meet in person, power it on, confirm it boots to Windows. Scammers push deposits and "shipping" — money changes hands **only after you've seen it work.**
2. **Verify the GPU is real.** Download **GPU-Z** (free) and confirm the card model and VRAM match the listing. Listings lie or mix up models.
3. **Load test.** Run something demanding for ~5 minutes (GPU-Z has a render test, or FurMark) and watch the temperature — it should stay **under ~80 °C**. Overheating = bad cooling or a worn card.
4. **Check the PSU wattage sticker** and that there's a proper power cable to the GPU.
5. **Listing red flags:** stock/template photos instead of real ones = a build-to-order dealer or a scam. Real seller = real photos, answers specific questions, lets you test.

### Optional extras (cheap, worth it)

- **A spare SSD for models** (~$30) — highly recommended, keeps everything tidy.
- **A USB WiFi adapter** (~$15) if the PC has no ethernet where it'll live. (This build used a TP-Link WiFi 6E USB adapter. ⚠️ **Honest warning:** cheap USB WiFi adapters can drop under sustained load — if you can run a real ethernet cable, do that instead. A powerline/MoCA kit ~$50 is the fix if the wall wiring is dead.)
- **A 4K HDMI/DisplayPort "dummy plug"** (~$8) — only needed later if you run the PC with **no monitor at all** and want fast remote desktop. It fakes a display so the GPU renders properly. Not needed for the AI studio itself.

---

## 💻 PART 2 — First boot & operating system

**OS choice: Windows 11** (what this build used — familiar, and it just works). Linux (Ubuntu) also works and is arguably cleaner for a headless server, but this guide is written for Windows. If you pick Linux, ask Claude to adapt the steps.

1. **Do a clean Windows install** on the SSD (not the big HDD). Download the Windows 11 installer from Microsoft, make a USB installer, and install fresh. Wipe whatever the previous owner left.
   - ⚠️ During install, **make sure Windows lands on the SSD**, not the big HDD. Check drive sizes in the installer.
2. **Install the NVIDIA driver.** Get the latest **Game Ready / Studio driver** from nvidia.com for your exact card. Reboot.
3. **Verify the GPU + CUDA.** Open PowerShell and run `nvidia-smi` — you should see your card, driver version, and VRAM. If that works, CUDA is available.
4. **Disable sleep** (critical for a headless server — it was sleeping mid-download and killing transfers):
   ```powershell
   powercfg /change standby-timeout-ac 0
   powercfg /change monitor-timeout-ac 0
   powercfg /hibernate off
   ```
5. **Note your Windows username** — you'll use it in paths throughout. This guide writes it as `<USER>` (e.g. `C:\Users\<USER>\`).

> 💡 **Windows quirk to know:** Windows 11 dropped the old `wmic` command. Use `Get-CimInstance` / `Get-Volume` in PowerShell instead. Also, processes you launch over SSH die when the SSH session ends — anything that must stay running needs a **Scheduled Task** (covered in Phase 7), not a background SSH process.

---

## 🌐 PART 3 — Network: reach the PC from your main computer

You want to control the PC from your Mac/laptop without fiddling with port forwarding or exposing anything to the internet. **Tailscale** makes this trivial and free.

### On BOTH machines: install Tailscale

1. Make a free account at **tailscale.com**.
2. Install the Tailscale app on **the PC** and on **your main computer**. Log both into the same account.
3. Each machine gets a stable private IP like `100.x.y.z`. From your main computer, you can now reach the PC at its Tailscale IP from anywhere.

Find the PC's Tailscale IP: on the PC, run `tailscale ip -4`. **Write it down** — you'll paste it into the config later. This guide calls it `<PC_TAILSCALE_IP>`.

### On the PC: enable SSH (so your main computer can send commands)

Windows has a built-in OpenSSH server. Enable it (run PowerShell **as Administrator**):

```powershell
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
```

### Set up key-based SSH login (no passwords)

Password SSH is awkward on Microsoft accounts — **use an SSH key** instead. On your **main computer**:

```bash
ssh-keygen -t ed25519          # press enter through the prompts if you don't have a key
ssh-copy-id <USER>@<PC_TAILSCALE_IP>   # or on Windows, append your public key manually
```

> On Windows the authorized-keys file for an admin user lives at `C:\ProgramData\ssh\administrators_authorized_keys` (not the usual `~/.ssh/authorized_keys`). If `ssh-copy-id` isn't available, ask Claude to help you place the key correctly — it's the one fiddly bit.

**Test it:** from your main computer, `ssh <USER>@<PC_TAILSCALE_IP>` should log you in without a password. ✅ That's the backbone of everything.

### ⚠️ The one Tailscale gotcha that will bite you later

By default, **Tailscale on Windows only runs while a user is logged in.** For a headless server that reboots on its own, that means after a reboot the PC is healthy but *invisible* on the network. Fix it now (run on the PC):

```powershell
& 'C:\Program Files\Tailscale\tailscale.exe' set --unattended=true
```

This makes Tailscale run as a background service always. (Took a while to diagnose the first time — the PC was fine but unreachable after a reboot until this was set.)

---

## ⚙️ PART 4 — Install ComfyUI (the AI engine)

**ComfyUI** is the workhorse — it runs the actual models. Everything the Studio app does is a workflow submitted to ComfyUI's API.

On the PC:

1. **Install Python 3.11** (from python.org — 3.11 specifically is well-tested here). Check "Add to PATH."
2. **Install git** (git-scm.com).
3. **Clone ComfyUI** and make a virtual environment:
   ```powershell
   cd C:\Users\<USER>
   git clone https://github.com/comfyanonymous/ComfyUI
   cd ComfyUI
   python -m venv venv
   .\venv\Scripts\activate
   ```
4. **Install PyTorch with CUDA — this is the step people break.** Install a matching torch + torchvision + torchaudio for CUDA 12.4:
   ```powershell
   pip install torch==2.6.0 torchvision torchaudio==2.6.0 --index-url https://download.pytorch.org/whl/cu124
   ```
   > ⚠️ **THE #1 GOTCHA:** `torchaudio` will otherwise install a *newer* version that doesn't match torch, causing a cryptic `WinError 127` DLL failure. **Pin torchaudio to the same version as torch** (both `2.6.0` here) from the same CUDA index. If you ever `pip install` something later that drags in a mismatched torch, ComfyUI breaks — always re-check `python -c "import torch;print(torch.__version__)"` after installs.
5. **Install ComfyUI's requirements:**
   ```powershell
   pip install -r requirements.txt
   ```
6. **First run:**
   ```powershell
   python main.py --listen 0.0.0.0 --port 8188
   ```
   You should see it start and print a local URL. Leave it running.

### Reach ComfyUI from your main computer (SSH tunnel)

Windows Firewall blocks port 8188 even over Tailscale, so instead of poking holes, **tunnel it over SSH**. On your **main computer**:

```bash
ssh -N -L 8188:127.0.0.1:8188 <USER>@<PC_TAILSCALE_IP>
```

Now open **http://localhost:8188** on your main computer — that's ComfyUI's interface, served from the PC. ✅

### Point ComfyUI at a models drive (recommended)

If you added a spare SSD (say it's drive `E:`), make a models folder there and tell ComfyUI to look in it — keeps your C: drive from filling up. Create `E:\ComfyUI-Models\` with subfolders, then create `C:\Users\<USER>\ComfyUI\extra_model_paths.yaml`:

```yaml
e_models:
  base_path: E:\ComfyUI-Models
  checkpoints: checkpoints
  vae: vae
  loras: loras
  upscale_models: upscale_models
  controlnet: controlnet
  clip: clip
  clip_vision: clip_vision
  unet: unet
  diffusion_models: unet
  animatediff_models: animatediff_models
  ipadapter: ipadapter
  ultralytics_bbox: ultralytics/bbox
  ultralytics_segm: ultralytics/segm
  ultralytics: ultralytics
  sams: sams
```

Create those subfolders under `E:\ComfyUI-Models\`. (Skip this and just use ComfyUI's default `models/` folder if you didn't add a second drive.)

> 💡 **Tight on C: space later?** You can move the whole ComfyUI folder (the venv is ~9 GB) to another drive and leave a **directory junction** behind: `robocopy` it over, delete the original, then `cmd /c mklink /J C:\Users\<USER>\ComfyUI E:\ComfyUI`. Everything keeps working through the junction. (Stop ComfyUI first to release file locks.)

---

## 🧩 PART 5 — Download the models

**Start minimal.** Get **one image model** working end-to-end before downloading everything. The full list is in Phase 7; here's the starter.

**Download command pattern** (run on the PC, or ask Claude to script it). Using `curl`:
```powershell
curl.exe -L -o "E:\ComfyUI-Models\checkpoints\<filename>" "<url>"
```

### Starter model (get this first)

| Model | File → folder | Size | URL |
|---|---|---|---|
| **Juggernaut XL Lightning** (fast, great all-rounder SDXL) | `Juggernaut_XL_Lightning.safetensors` → `checkpoints` | ~6.8 GB | Search HuggingFace for "Juggernaut XL Lightning safetensors" — grab the fp16 `.safetensors`. |

Once it's in the folder, **restart ComfyUI** (so it re-scans models), and you're ready to bring up the Studio app (Phase 6). Come back to Phase 7 to add the rest.

> 🔑 **Why no API keys?** Many models on HuggingFace are "gated" (require accepting a license + a token). This guide deliberately uses **non-gated mirrors** for everything, so you never need a token. If a download returns HTTP 401, it's gated — find a non-gated mirror (the community always has one; ask Claude).

---

## 🖥️ PART 6 — The Studio web app

This is the single-file web app that ties it all together. The full source is in **Appendix A (studio.py)** and **Appendix B (studio.html)** at the end of this document. Claude: write those two files out verbatim, then have the user fill in the CONFIG block at the top of `studio.py`.

### Setup

1. On your **main computer**, make a folder, e.g. `~/pc-ai/scripts/`, and save **`studio.py`** and **`studio.html`** there (from the appendices). Also make `~/pc-ai/outputs/` (where results are copied) and a `~/pc-ai/config/studio_models.json` (Appendix C).
2. **Edit the CONFIG block** at the top of `studio.py`:
   - `PC_USER` = your Windows username
   - `PC_HOST` = your `<PC_TAILSCALE_IP>`
   - `PC_HOME` = `C:/Users/<USER>` (forward slashes)
3. **Make sure the SSH tunnel to 8188 is running** (Phase 4).
4. **Run it:**
   ```bash
   python3 ~/pc-ai/scripts/studio.py
   ```
   Open **http://localhost:8793** — that's your studio. Type a prompt, hit Generate. 🎉

### Reach it from your phone

The Studio binds to `0.0.0.0`, so any device on your Tailscale network can open it at `http://<YOUR-MAIN-COMPUTER-TAILSCALE-IP>:8793`. On your phone (with Tailscale installed and logged in), open that URL — the full studio, on your phone.

> ⚠️ **Security note:** the Studio app has no login. That's fine on a private Tailscale network (only your own devices). **Do not** forward its port to the public internet or bind it anywhere reachable from outside your tailnet.

---

## 🚀 PART 7 — Add all the capabilities

Now expand. Each capability = **(sometimes) a custom node** + **a model** + it's already wired into the Studio code you deployed. Install custom nodes by `git clone`-ing into `C:\Users\<USER>\ComfyUI\custom_nodes\` then restarting ComfyUI.

> ⚠️ **Golden rule for custom-node installs:** many nodes list `torch` in their `requirements.txt`. **Never blindly `pip install -r` those** — install their *other* deps individually and skip torch/torchvision/torchaudio, or you'll break the CUDA torch you carefully pinned. Always re-check `import torch` after. And **stop ComfyUI before pip-installing** anything that touches loaded DLLs.

### Install ComfyUI-Manager first (makes everything easier)
```
git clone https://github.com/ltdrdata/ComfyUI-Manager C:\Users\<USER>\ComfyUI\custom_nodes\ComfyUI-Manager
```
It adds a "Manager" button in ComfyUI for 1-click missing-node installs.

### The full model + node catalog

Everything below was installed and tested on the 8 GB build. Folders are relative to your models dir (`E:\ComfyUI-Models\` or ComfyUI's `models\`). All URLs are non-gated.

**More image models:**
| Capability | Model file → folder | Node needed | Source |
|---|---|---|---|
| DreamShaper (fast SD1.5, also powers video) | `DreamShaper_8_pruned.safetensors` → checkpoints | — | HF: `Lykon/DreamShaper` |
| RealVis XL (photoreal) | `realvisxl.safetensors` → checkpoints | — | HF: RealVisXL V5 Lightning |
| CyberRealistic Pony (photoreal people) | `CyberRealisticPony_V18.safetensors` → checkpoints | — | HF `cyberdelia/CyberRealisticPony` → `CyberRealisticPony_V18.0_F16.safetensors` |
| SD 3.5 Medium (readable **text in images**) | `sd3.5_medium.safetensors` → checkpoints | — (native) | HF `Comfy-Org/stable-diffusion-3.5-fp8` → `sd3.5_medium_incl_clips_t5xxlfp8scaled.safetensors` (all-in-one) |
| **Flux.1 schnell** (best prompt-following) | `flux1-schnell-Q4_K_S.gguf` → **unet** + `t5-v1_1-xxl-encoder-Q4_K_M.gguf` → clip + `clip_l.safetensors` → clip + `ae.safetensors` → vae | **ComfyUI-GGUF** (`git clone https://github.com/city96/ComfyUI-GGUF` then `pip install gguf`) | HF `city96/FLUX.1-schnell-gguf`, `city96/t5-v1_1-xxl-encoder-gguf`, `comfyanonymous/flux_text_encoders` (clip_l), `ffxvs/vae-flux` (ae — non-gated mirror) |

> **Pony models need magic words:** CyberRealistic Pony (and any Pony-based model) produces bad output unless the prompt starts with quality tags like `score_9, score_8_up, score_7_up`. The Studio config handles this via a per-model `prefix` — see `studio_models.json` (Appendix C). Also: Pony has a strong bias toward young idealized subjects; prompt explicitly for variety.
> **SD 3.5 is not a drop-in:** it needs a 16-channel latent and a sigma shift. The Studio code handles this via `"engine":"sd3"` in the model config.
> **Flux ignores negative prompts** (it's guidance-distilled) — the Studio UI auto-disables the negative box when Flux is selected.

**Video:**
| Capability | Model → folder | Node | Source |
|---|---|---|---|
| **LTX-Video** (modern text→video & image→video, 24fps) | `ltxv-2b-0.9.6-distilled.safetensors` → checkpoints + `t5xxl_fp8_e4m3fn_scaled.safetensors` → clip | — (native) | HF `Lightricks/LTX-Video` + `Comfy-Org/stable-diffusion-3.5-fp8` (t5) |
| Classic text→video | `mm_sd_v15_v2.ckpt` → animatediff_models | **ComfyUI-AnimateDiff-Evolved** + **ComfyUI-VideoHelperSuite** | github Kosinkadink |
| Classic image→video | `svd_xt_1_1.safetensors` → checkpoints | (VideoHelperSuite) | HF stabilityai SVD |
| **RIFE smoothing** (2× the framerate of any clip) | (auto-downloads `rife47.pth`) | **ComfyUI-Frame-Interpolation** (Fannovel16) | github |

> **LTX quirk:** frame count must be `8n+1` (e.g. 25, 49, 73). The Studio code computes this for you.

**Image editing & relighting:**
| Capability | Model → folder | Node | Source |
|---|---|---|---|
| **Flux Kontext** (edit by instruction: "make it night") | `flux1-kontext-dev-Q4_K_S.gguf` → **unet** (reuses Flux's t5/clip/ae) | ComfyUI-GGUF (already have) | HF `QuantStack/FLUX.1-Kontext-dev-GGUF` (non-gated; BFL's own is gated) |
| **IC-Light** (relight a subject to match a scene) | `iclight_sd15_fc.safetensors` → **unet** | **ComfyUI-IC-Light** (kijai) | HF `lllyasviel/ic-light` |

> Kontext is slow on 8 GB (~3 min cold, ~90 s warm — it's a 12B model). IC-Light: strong relights can recolor the subject, so name its color in the prompt.

**3D:**
| Capability | Model → folder | Node | Source |
|---|---|---|---|
| **Hunyuan3D** (image → solid 3D mesh) | `hunyuan3d-dit-v2.safetensors` → checkpoints | — (native, built into ComfyUI) | HF `tencent/Hunyuan3D-2` → `hunyuan3d-dit-v2-0/model.fp16.safetensors` |
| MoGe (photo → scene depth mesh) | `moge_2_vitl_normal_fp16.safetensors` → geometry_estimation | (MoGe nodes) | HF Ruicheng/moge |

> Hunyuan3D native support is **geometry only** (no color/texture yet) — a true spin-around solid mesh. ~65 s on 8 GB at octree 256.

**Audio:**
| Capability | Model → folder | Node/tool | Source |
|---|---|---|---|
| **Music** (full tracks w/ vocals) | `ace_step_v1_3.5b.safetensors` → checkpoints | — (native) | HF `Comfy-Org/ACE-Step_ComfyUI_repackaged` → `all_in_one/` |
| **Speech — Piper** (fast) | piper Windows binary + voices | standalone (not a ComfyUI node) | github `rhasspy/piper` releases + `rhasspy/piper-voices` |
| **Speech — Kokoro** (natural) | `kokoro-v1.0.onnx` + `voices-v1.0.bin` in own venv | standalone | github `thewh1teagle/kokoro-onnx` releases |

> **TTS runs outside ComfyUI on purpose** — as small PC-side scripts (like Whisper below), because the fancy TTS ComfyUI nodes have fragile dependencies that break torch. See Appendix C for `tts.ps1` (Piper) and `kokoro_tts.py`.

**Understanding & utilities:**
| Capability | Model → folder | Node/tool | Source |
|---|---|---|---|
| **Transcription** (Whisper) | (auto-downloads `large-v3-turbo`) | `pip install faster-whisper` in the venv + `whisper_transcribe.py` (Appendix C) | — |
| **Describe image → prompt** (Florence-2) | (auto-downloads) | **ComfyUI-Florence2** (kijai) + `pip install matplotlib accelerate` | github |
| **Smart select** (SAM2 click-to-mask) | (auto-downloads `sam2.1_hiera_base_plus`) | **ComfyUI-segment-anything-2** (kijai) | github |
| **Background removal** — photo (RMBG-2.0) | (auto-downloads) | **ComfyUI-RMBG** (1038lab) + `pip install timm` | github |
| Background removal — video (RVM) | (auto) | **ComfyUI-Video-Matting** (Fannovel16) | github |
| **ControlNet** (depth/edges/pose) | `controlnet_union_sdxl_promax.safetensors` → controlnet | **comfyui_controlnet_aux** (Fannovel16) | HF xinsir union |
| **IP-Adapter** (style transfer) | `ip-adapter-plus_sdxl_vit-h.safetensors` → ipadapter + `CLIP-ViT-H-14-laion2B.safetensors` → clip_vision | **ComfyUI_IPAdapter_plus** (cubiq) | HF |
| **Face detailer** (fix small faces) | `face_yolov8m.pt` → ultralytics/bbox | **ComfyUI-Impact-Pack** + **Impact-Subpack** + `pip install ultralytics` | github |
| **Face restoration** (CodeFormer/GFPGAN) | `codeformer.pth` + `GFPGANv1.4.pth` → **models\facerestore_models** | **facerestore_cf** (mav-rik) | github release / HF `Gourieff/ReActor` |
| **Upscale 2×/4×** | `4x-UltraSharp.pth` → upscale_models | — | HF/openmodeldb |

> **facerestore_cf gotcha:** it hardcodes its model path, ignoring `extra_model_paths.yaml`. Put its two models in `ComfyUI\models\facerestore_models\` specifically.
> **Face fixing rule learned the hard way:** always fix a face at ~1024px, *then* upscale — never fix a face on an already-4K image (SDXL duplicates features above ~1024px → extra noses). For detail at 4K, go progressively: fix@1024 → 2× → fix@2048 → 2×. And keep face-fix denoise low (~0.30) or it changes the person's identity.

After adding a model, **restart ComfyUI** so it re-indexes. After adding a custom node, restart ComfyUI (and install its non-torch deps first).

---

## 🛡️ PART 8 — Make it bulletproof (headless & self-healing)

The goal: the PC **boots straight into serving the studio with nobody logged in**, and **restarts itself** if anything crashes or the power blips. All on the PC.

### 1. ComfyUI starts at boot — without a login, without a stored password

Windows can run a scheduled task "whether the user is logged on or not" using **S4U** logon — no password stored. First make a launch script `C:\Users\<USER>\comfy_launch.ps1`:

```powershell
Set-Location C:\Users\<USER>\ComfyUI
$py = "C:\Users\<USER>\ComfyUI\venv\Scripts\python.exe"
& $py main.py --listen 0.0.0.0 --port 8188 --output-directory "D:\AI-Output" *>&1 | Out-File -Append C:\Users\<USER>\comfy_run.log
```
(Point `--output-directory` at your big drive.) Then register the boot task (PowerShell as admin):

```powershell
$action    = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Users\<USER>\comfy_launch.ps1"
$trigger   = New-ScheduledTaskTrigger -AtStartup
$principal = New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\<USER>" -LogonType S4U -RunLevel Highest
$settings  = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 2)
Register-ScheduledTask -TaskName "comfyui_boot" -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
```

> ✅ **This works** — CUDA compute runs fine in the pre-login "session 0". Verified by generating images with the monitor physically unplugged and nobody logged in.

### 2. A crash-watchdog

Save `C:\Users\<USER>\comfy_watchdog.ps1`:

```powershell
$ok = $false
foreach ($i in 1..2) {
  try { $r = Invoke-WebRequest -Uri "http://127.0.0.1:8188/system_stats" -TimeoutSec 8 -UseBasicParsing
        if ($r.StatusCode -eq 200) { $ok = $true; break } } catch {}
  Start-Sleep 15
}
if (-not $ok) {
  Get-Process python -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
  Start-Sleep 3
  schtasks /run /tn comfyui_boot | Out-Null
  Add-Content C:\Users\<USER>\comfy_watchdog.log ((Get-Date).ToString('s') + "  restarted ComfyUI")
}
```
Register it to run every 5 minutes:
```powershell
schtasks /create /tn comfy_watchdog /tr "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File C:\Users\<USER>\comfy_watchdog.ps1" /sc minute /mo 5 /it /f
```

### 3. Don't let Windows Update reboot on you mid-render
```powershell
New-Item -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU\" -Force | Out-Null
Set-ItemProperty "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU\" -Name NoAutoRebootWithLoggedOnUsers -Value 1 -Type DWord
```

### 4. Keep the tunnel + studio alive on your main computer

On a Mac, use a small `launchd` agent that keeps the SSH tunnel up (a while-loop that reconnects if it drops) and one that keeps `studio.py` running. On Linux, a `systemd --user` service. Ask Claude to generate these for your OS — the pattern is "run this command, restart it if it exits."

> **Result:** power cut → PC reboots → ComfyUI auto-starts in ~1–3 minutes (longer with many custom nodes — the port stays dead until it finishes loading, that's normal) → watchdog keeps it alive → your main computer's tunnel self-heals → studio back online, untouched.

---

## 📺 PART 9 (optional) — Remote desktop into the PC

You don't need this for the AI studio (that's all web-based). But if you want the actual Windows desktop — to run other apps on the PC — you have two options:

- **RDP** (built into Windows Pro): enable it, connect with Microsoft Remote Desktop / the "Windows App". ⚠️ Two gotchas: (1) enabling RDP the first time needs a **reboot** (restarting the service isn't enough); (2) if "only allow Windows Hello sign-in" is ON in Settings → Accounts → Sign-in options, **turn it OFF** or RDP rejects your password. Also, RDP stutters if the PC has **no monitor** attached — that's when the cheap **dummy plug** earns its keep.
- **Sunshine + Moonlight** (free, GPU-accelerated): much smoother than RDP for anything visual, uses the NVIDIA encoder. ⚠️ Gotcha: Sunshine can't ARP-resolve a Tailscale IP, so **pair Moonlight to the PC's LAN IP, not its Tailscale IP** when you're on the same network.

Honestly, for a headless AI box you may never need either — SSH + the web studio cover the real work.

---

## 🔧 PART 10 — Troubleshooting (every one of these was hit for real)

| Symptom | Cause & fix |
|---|---|
| `WinError 127` / DLL load fail after installing torch | torchaudio version doesn't match torch. Reinstall both pinned to the same version from the same CUDA index (`torch==2.6.0 torchaudio==2.6.0 --index-url .../cu124`). |
| ComfyUI works locally on the PC but `http://localhost:8188` is dead on your main computer | The SSH tunnel dropped. Restart it: `ssh -N -L 8188:127.0.0.1:8188 <USER>@<PC_TAILSCALE_IP>`. |
| PC unreachable after a reboot (but it's on and fine) | Tailscale isn't running pre-login. Set `tailscale set --unattended=true` (Phase 3). |
| The Studio page loads but generation fails / "Failed to fetch" | The PC or tunnel is down — check the PC is reachable first. Often a flaky USB WiFi adapter dropping under load; replug it, or move to wired/powerline. |
| A model download returns HTTP 401 | It's gated. Find a non-gated mirror (community always has one; ask Claude). |
| Selecting a model errors with "incomplete metadata, file not fully covered" | You picked it while it was still downloading. Wait for the full file. |
| A custom node install broke ComfyUI | It dragged in a mismatched torch. Reinstall the pinned torch; install node deps individually skipping torch/torchvision/torchaudio next time. |
| Out-of-memory on the 8 GB card | It's swapping big models. More system RAM helps a lot. Or the model's too big — use the GGUF 4-bit version. First-ever load of a 7 GB model in a session is always slow (disk-bound); the second run is fast. |
| Face-fix made the face waxy / a second nose / a different person | Wrong order or too-high denoise. Fix at ~1024px then upscale — never fix on a 4K image. Keep denoise ~0.30. For 4K detail, go fix@1024 → 2× → fix@2048 → 2×. |
| ComfyUI took forever to start after a reboot | Normal with many custom nodes — it loads each one. Port 8188 stays dead until it's done (can be a few minutes). |
| Two file transfers corrupted a file | Two concurrent `scp`/downloads to the same path clobber each other. One at a time. |
| Windows `wmic` command not found | Windows 11 removed it — use `Get-CimInstance` / `Get-Volume`. |
| A background process you started over SSH died | SSH-launched processes die with the session. Use a Scheduled Task for anything persistent. |

---

## 🧭 Suggested order of attack (for Claude guiding a first-timer)

1. **Buy the hardware** (Part 1) — get the GPU right, everything else is negotiable.
2. **Windows + driver + disable sleep** (Part 2). Verify `nvidia-smi`.
3. **Tailscale on both machines + SSH keys + unattended mode** (Part 3). Verify passwordless SSH.
4. **ComfyUI + pinned torch** (Part 4). Verify the web UI over the tunnel.
5. **One model** (Part 5). Verify a generation in ComfyUI itself.
6. **Deploy the Studio app** (Part 6, Appendices). Verify a generation through the studio, then from your phone.
7. **Persistence** (Part 8) — boot task + watchdog. Reboot-test it.
8. **Add capabilities one at a time** (Part 7), testing each. Don't rush all 20 at once.

Celebrate at step 6 — that's a working local AI studio. Everything after is bonus.

---

*The full working code follows. Claude: write these out verbatim; the user edits only the CONFIG block at the top of `studio.py`.*

---

## Appendix A — `studio.py` (the web server & AI orchestration)

> **Before running:** find-and-replace `YOUR_PC_USER` (your Windows username) and `YOUR_PC_IP` (your PC's Tailscale IP) throughout this file. They appear a handful of times. Save as `~/pc-ai/scripts/studio.py` on your main computer.

```python
#!/usr/bin/env python3
"""Unified AI Studio - one localhost app for everything on the PC GPU:
Image (txt2img + img2img), Video (text->video AnimateDiff + image->video SVD),
3D (photo->mesh MoGe), Upscale, and a Gallery. Drives ComfyUI via the 8188 tunnel."""
import http.server, socketserver, json, urllib.request, time, os, threading, random, base64, subprocess

COMFY = "http://localhost:8188"
PORT = 8793
OUTDIR = os.path.expanduser("~/pc-ai/outputs")
os.makedirs(OUTDIR, exist_ok=True)
_lock = threading.Lock()  # 8GB card: one job at a time

CONFIG = os.path.expanduser("~/pc-ai/config/studio_models.json")
_FALLBACK = {
    "juggernaut": {"label":"Juggernaut XL","ckpt":"Juggernaut_XL_Lightning.safetensors","steps":6,"cfg":2.0,"sampler":"dpmpp_sde","scheduler":"karras",
                   "sizes":{"square":[1024,1024],"portrait":[832,1216],"landscape":[1216,832]}},
}
def load_models():
    """Read the model registry fresh each time so edits take effect on browser refresh (no restart)."""
    try:
        with open(CONFIG) as f: return json.load(f)
    except Exception:
        return _FALLBACK
NEG = "blurry, low quality, deformed, watermark, text, ugly"

def cpost(p,d): return json.load(urllib.request.urlopen(urllib.request.Request(COMFY+p,data=json.dumps(d).encode(),headers={"Content-Type":"application/json"}),timeout=30))
def cget(p): return json.load(urllib.request.urlopen(COMFY+p,timeout=30))

def upload_to_comfy(img_bytes, filename=None):
    filename = filename or "in_%d.png"%random.randint(1,9999999)
    b="----studioB"
    body=(("--%s\r\n"%b)+('Content-Disposition: form-data; name="image"; filename="%s"\r\n'%filename)+"Content-Type: image/png\r\n\r\n").encode()+img_bytes+("\r\n--%s--\r\n"%b).encode()
    req=urllib.request.Request(COMFY+"/upload/image",data=body,headers={"Content-Type":"multipart/form-data; boundary=%s"%b})
    return json.load(urllib.request.urlopen(req,timeout=30))["name"]

META = os.path.join(OUTDIR, "_meta.json")
_meta_lock = threading.Lock()

def load_meta():
    try:
        with open(META) as f: return json.load(f)
    except Exception: return {}

def save_meta(filename, endpoint, body):
    """Record what produced an output file (prompt, model, settings) for the UI."""
    kind = endpoint.replace("/api/","")
    info = {"kind":kind, "ts":int(time.time())}
    for k in ("prompt","negative","model","aspect","tags","lyrics","text","voice",
              "control_type","method","strength","seconds","frames",
              "filename","denoise","fidelity","factor","octree"):
        v = body.get(k)
        if v not in (None,"",[]) and not (isinstance(v,str) and v.startswith("data:")):
            info[k] = v if not isinstance(v,str) else v[:1200]
    try:
        with _meta_lock:
            m = load_meta(); m[filename] = info
            if len(m) > 1200:  # keep it from growing forever
                m = dict(sorted(m.items(), key=lambda kv: kv[1].get("ts",0), reverse=True)[:1200])
            tmp = META + ".tmp"
            with open(tmp,"w") as f: json.dump(m,f)
            os.replace(tmp, META)
    except Exception: pass

_cancel = threading.Event()

def cancel_job():
    """Stop whatever is running. Tells ComfyUI to interrupt the GPU work immediately
    and flags _run() so the waiting request returns right away."""
    _cancel.set()
    ok=False
    try:
        urllib.request.urlopen(urllib.request.Request(COMFY+"/interrupt",data=b"",method="POST"),timeout=10)
        ok=True
    except Exception: pass
    try: cpost("/queue",{"clear":True})   # drop anything queued behind it
    except Exception: pass
    return {"ok":ok,"cancelled":True}

def _run(wf, out_node, keys=("images","gifs","video")):
    """submit workflow, wait, save the output file, return dict."""
    _cancel.clear()
    t0=time.time(); pid=cpost("/prompt",{"prompt":wf})["prompt_id"]
    for _ in range(600):
        if _cancel.is_set():
            try: urllib.request.urlopen(urllib.request.Request(COMFY+"/interrupt",data=b"",method="POST"),timeout=10)
            except Exception: pass
            return {"error":"cancelled"}
        time.sleep(1.2); hist=cget("/history/"+pid)
        if pid in hist:
            o=hist[pid].get("outputs",{})
            if out_node in o:
                for k in keys:
                    if o[out_node].get(k):
                        item=o[out_node][k][0]
                        url="%s/view?filename=%s&subfolder=%s&type=%s"%(COMFY,item["filename"],item.get("subfolder",""),item.get("type","output"))
                        data=urllib.request.urlopen(url,timeout=180).read()
                        open(os.path.join(OUTDIR,item["filename"]),"wb").write(data)
                        return {"filename":item["filename"],"seconds":round(time.time()-t0,1)}
            st=hist[pid].get("status",{})
            if st.get("status_str")=="error": return {"error":json.dumps(st.get("messages",st))[:500]}
    return {"error":"timeout"}

def gen_image(prompt, model_key, aspect, image_b64=None, strength=0.65, negative="", lora="", lora_strength=0.8):
    models=load_models(); m=models.get(model_key) or next(iter(models.values())); w,h=m["sizes"].get(aspect,m["sizes"]["square"])
    seed=random.randint(1,2_000_000_000); steps=m["steps"]
    # per-model prompt shaping: Pony-family models need score_* tags to perform at all
    pos=(m.get("prefix","")+prompt) if prompt else m.get("prefix","")
    neg=m.get("neg") or NEG
    # user's own negative is appended to the model's baseline so quality terms are kept
    if negative and negative.strip(): neg=neg+", "+negative.strip()
    wf={"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":m["ckpt"]}},
        "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":pos}},
        "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":neg}},
        "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},
        "9":{"class_type":"SaveImage","inputs":{"images":["8",0],"filename_prefix":"studio"}}}
    # optional LoRA: applies to model+clip right after the checkpoint
    base_model=["4",0]; base_clip=["4",1]
    if lora:
        try: ls=max(0.0,min(2.0,float(lora_strength)))
        except Exception: ls=0.8
        wf["13"]={"class_type":"LoraLoader","inputs":{"model":["4",0],"clip":["4",1],"lora_name":lora,
                  "strength_model":ls,"strength_clip":ls}}
        base_model=["13",0]; base_clip=["13",1]
        wf["6"]["inputs"]["clip"]=base_clip; wf["7"]["inputs"]["clip"]=base_clip
    # SD3.5 uses a 16-channel latent and wants the SD3 sigma shift; SDXL/SD1.5 use the classic path
    is_sd3 = m.get("engine")=="sd3"
    model_ref=base_model
    if is_sd3:
        wf["12"]={"class_type":"ModelSamplingSD3","inputs":{"model":base_model,"shift":float(m.get("shift",3.0))}}
        model_ref=["12",0]
    latent_cls = "EmptySD3LatentImage" if is_sd3 else "EmptyLatentImage"
    if image_b64:
        raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
        strength=max(0.2,min(0.95,float(strength))); steps=max(steps,int(round(steps/strength)))
        wf["10"]={"class_type":"LoadImage","inputs":{"image":name}}
        wf["11"]={"class_type":"VAEEncode","inputs":{"pixels":["10",0],"vae":["4",2]}}
        wf["3"]={"class_type":"KSampler","inputs":{"model":model_ref,"positive":["6",0],"negative":["7",0],"latent_image":["11",0],"seed":seed,"steps":steps,"cfg":m["cfg"],"sampler_name":m["sampler"],"scheduler":m["scheduler"],"denoise":strength}}
    else:
        wf["5"]={"class_type":latent_cls,"inputs":{"width":w,"height":h,"batch_size":1}}
        wf["3"]={"class_type":"KSampler","inputs":{"model":model_ref,"positive":["6",0],"negative":["7",0],"latent_image":["5",0],"seed":seed,"steps":steps,"cfg":m["cfg"],"sampler_name":m["sampler"],"scheduler":m["scheduler"],"denoise":1.0}}
    return _run(wf,"9")

def gen_flux(prompt, aspect):
    """Flux.1 schnell (GGUF Q4, fits 8GB): best-in-class text->image, 4 steps. ~40-70s on the 2070S."""
    sizes={"square":(1024,1024),"portrait":(896,1152),"landscape":(1152,896)}
    w,h=sizes.get(aspect,(1024,1024))
    wf={"1":{"class_type":"UnetLoaderGGUF","inputs":{"unet_name":"flux1-schnell-Q4_K_S.gguf"}},
        "2":{"class_type":"DualCLIPLoaderGGUF","inputs":{"clip_name1":"t5-v1_1-xxl-encoder-Q4_K_M.gguf","clip_name2":"clip_l.safetensors","type":"flux"}},
        "3":{"class_type":"VAELoader","inputs":{"vae_name":"ae.safetensors"}},
        "4":{"class_type":"CLIPTextEncode","inputs":{"clip":["2",0],"text":prompt}},
        "5":{"class_type":"FluxGuidance","inputs":{"conditioning":["4",0],"guidance":3.5}},
        "6":{"class_type":"ConditioningZeroOut","inputs":{"conditioning":["4",0]}},
        "7":{"class_type":"ModelSamplingFlux","inputs":{"model":["1",0],"max_shift":1.15,"base_shift":0.5,"width":w,"height":h}},
        "8":{"class_type":"EmptySD3LatentImage","inputs":{"width":w,"height":h,"batch_size":1}},
        "9":{"class_type":"KSampler","inputs":{"model":["7",0],"positive":["5",0],"negative":["6",0],"latent_image":["8",0],"seed":random.randint(1,2**31),"steps":4,"cfg":1.0,"sampler_name":"euler","scheduler":"simple","denoise":1.0}},
        "10":{"class_type":"VAEDecode","inputs":{"samples":["9",0],"vae":["3",0]}},
        "11":{"class_type":"SaveImage","inputs":{"images":["10",0],"filename_prefix":"studio_flux"}}}
    return _run(wf,"11")

def gen_music(tags, lyrics="", seconds=30):
    """ACE-Step 3.5B (native): text tags (+ optional lyrics) -> full music track. ~1-2s of gen per second of audio."""
    seconds=max(5.0,min(120.0,float(seconds)))
    wf={"1":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"ace_step_v1_3.5b.safetensors"}},
        "8":{"class_type":"ModelSamplingSD3","inputs":{"model":["1",0],"shift":5.0}},
        "2":{"class_type":"TextEncodeAceStepAudio","inputs":{"clip":["1",1],"tags":str(tags)[:600],"lyrics":str(lyrics)[:3000],"lyrics_strength":1.0}},
        "3":{"class_type":"TextEncodeAceStepAudio","inputs":{"clip":["1",1],"tags":"","lyrics":"","lyrics_strength":1.0}},
        "4":{"class_type":"EmptyAceStepLatentAudio","inputs":{"seconds":seconds,"batch_size":1}},
        "5":{"class_type":"KSampler","inputs":{"model":["8",0],"positive":["2",0],"negative":["3",0],"latent_image":["4",0],"seed":random.randint(1,2**31),"steps":50,"cfg":5.0,"sampler_name":"euler","scheduler":"simple","denoise":1.0}},
        "6":{"class_type":"VAEDecodeAudio","inputs":{"samples":["5",0],"vae":["1",2]}},
        "7":{"class_type":"SaveAudioMP3","inputs":{"audio":["6",0],"filename_prefix":"studio_music","quality":"V0"}}}
    return _run(wf,"7",keys=("audio",))

KOKORO_VOICES={"af_heart","af_bella","af_sarah","af_nicole","am_adam","am_michael","bf_emma","bm_george"}

def gen_tts(text, voice="hfc_female"):
    """Local text-to-speech: Kokoro (natural) or Piper (fast) on the PC. Returns an mp3."""
    voices={"hfc_female":"en_US-hfc_female-medium.onnx","ryan":"en_US-ryan-high.onnx"}
    text=(text or "").strip()
    if not text: return {"error":"no text"}
    import base64 as _b64
    b=_b64.b64encode(text[:4000].encode("utf-8")).decode()
    out="tts_%d"%random.randint(1,9999999)
    localwav=os.path.join(OUTDIR,out+".wav")
    if voice in KOKORO_VOICES:
        r=subprocess.run(["ssh","-o","ControlMaster=no",_PC,
            'C:\\Users\\YOUR_PC_USER\\kokoro-env\\Scripts\\python.exe C:\\Users\\YOUR_PC_USER\\kokoro_tts.py %s %s C:\\Users\\YOUR_PC_USER\\kokoro\\%s.wav'%(b,voice,out)],
            capture_output=True,text=True,timeout=300)
        subprocess.run(["scp","-o","ControlMaster=no","%s:C:/Users/YOUR_PC_USER/kokoro/%s.wav"%(_PC,out),localwav],
            capture_output=True,timeout=90)
        subprocess.run(["ssh","-o","ControlMaster=no",_PC,'del C:\\Users\\YOUR_PC_USER\\kokoro\\%s.wav'%out],capture_output=True,timeout=30)
    else:
        vf=voices.get(voice,voices["hfc_female"])
        r=subprocess.run(["ssh","-o","ControlMaster=no",_PC,
            'powershell -NoProfile -File C:\\Users\\YOUR_PC_USER\\tts.ps1 %s %s %s'%(b,vf,out+".wav")],
            capture_output=True,text=True,timeout=180)
        subprocess.run(["scp","-o","ControlMaster=no","%s:C:/Users/YOUR_PC_USER/piper/%s.wav"%(_PC,out),localwav],
            capture_output=True,timeout=90)
    if not os.path.exists(localwav) or os.path.getsize(localwav)<1000:
        return {"error":"tts failed: "+(r.stderr or r.stdout or "no audio")[:300]}
    FF=_ffmpeg(); mp3=os.path.join(OUTDIR,out+".mp3")
    subprocess.run([FF,"-y","-i",localwav,"-b:a","160k",mp3],capture_output=True,timeout=90)
    try: os.remove(localwav)
    except Exception: pass
    subprocess.run(["ssh","-o","ControlPath="+_CM,_PC,'del C:\\Users\\YOUR_PC_USER\\piper\\%s.wav'%out],capture_output=True,timeout=30)
    return {"filename":out+".mp3","seconds":0}

def gen_ltx_video(prompt, image_b64=None, seconds=4, negative=""):
    """LTX-Video 2B distilled (native): modern text->video / image->video at 768x512/24fps.
    Distilled model: 8 steps, cfg 1.0 - fast even on 8GB."""
    neg=(negative or "").strip() or "low quality, worst quality, deformed, distorted, watermark, jittery"
    seconds=max(1,min(6,int(seconds)))
    frames=((seconds*24)//8)*8+1  # LTX needs length = 8n+1
    seed=random.randint(1,2**31)
    wf={
      "1":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"ltxv-2b-0.9.6-distilled.safetensors"}},
      "2":{"class_type":"CLIPLoader","inputs":{"clip_name":"t5xxl_fp8_e4m3fn_scaled.safetensors","type":"ltxv"}},
      "3":{"class_type":"CLIPTextEncode","inputs":{"clip":["2",0],"text":prompt}},
      "4":{"class_type":"CLIPTextEncode","inputs":{"clip":["2",0],"text":neg}},
      "8":{"class_type":"KSamplerSelect","inputs":{"sampler_name":"euler"}},
      "10":{"class_type":"VAEDecode","inputs":{"samples":["9",0],"vae":["1",2]}},
      "11":{"class_type":"VHS_VideoCombine","inputs":{"images":["10",0],"frame_rate":24.0,"loop_count":0,
            "filename_prefix":"studio_ltx","format":"video/h264-mp4","pingpong":False,"save_output":True}},
    }
    if image_b64:
        raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
        wf["12"]={"class_type":"LoadImage","inputs":{"image":name}}
        wf["5"]={"class_type":"LTXVImgToVideo","inputs":{"positive":["3",0],"negative":["4",0],"vae":["1",2],
                 "image":["12",0],"width":768,"height":512,"length":frames,"batch_size":1,"strength":1.0}}
        wf["6"]={"class_type":"LTXVConditioning","inputs":{"positive":["5",0],"negative":["5",1],"frame_rate":24.0}}
        latent=["5",2]
    else:
        wf["5"]={"class_type":"EmptyLTXVLatentVideo","inputs":{"width":768,"height":512,"length":frames,"batch_size":1}}
        wf["6"]={"class_type":"LTXVConditioning","inputs":{"positive":["3",0],"negative":["4",0],"frame_rate":24.0}}
        latent=["5",0]
    wf["7"]={"class_type":"LTXVScheduler","inputs":{"steps":8,"max_shift":2.05,"base_shift":0.95,
             "stretch":True,"terminal":0.1,"latent":latent}}
    wf["9"]={"class_type":"SamplerCustom","inputs":{"model":["1",0],"add_noise":True,"noise_seed":seed,"cfg":1.0,
             "positive":["6",0],"negative":["6",1],"sampler":["8",0],"sigmas":["7",0],"latent_image":latent}}
    return _run(wf,"11")

def gen_kontext(prompt, image_b64):
    """Flux Kontext dev (GGUF Q4): edit an image by describing the change."""
    if not image_b64: return {"error":"needs an image"}
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    wf={
     "1":{"class_type":"UnetLoaderGGUF","inputs":{"unet_name":"flux1-kontext-dev-Q4_K_S.gguf"}},
     "2":{"class_type":"DualCLIPLoaderGGUF","inputs":{"clip_name1":"t5-v1_1-xxl-encoder-Q4_K_M.gguf","clip_name2":"clip_l.safetensors","type":"flux"}},
     "3":{"class_type":"VAELoader","inputs":{"vae_name":"ae.safetensors"}},
     "4":{"class_type":"LoadImage","inputs":{"image":name}},
     "5":{"class_type":"FluxKontextImageScale","inputs":{"image":["4",0]}},
     "6":{"class_type":"VAEEncode","inputs":{"pixels":["5",0],"vae":["3",0]}},
     "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["2",0],"text":prompt}},
     "8":{"class_type":"ReferenceLatent","inputs":{"conditioning":["7",0],"latent":["6",0]}},
     "9":{"class_type":"FluxGuidance","inputs":{"conditioning":["8",0],"guidance":2.5}},
     "10":{"class_type":"ConditioningZeroOut","inputs":{"conditioning":["7",0]}},
     "11":{"class_type":"KSampler","inputs":{"model":["1",0],"seed":random.randint(1,2**31),"steps":20,"cfg":1.0,
           "sampler_name":"euler","scheduler":"simple","positive":["9",0],"negative":["10",0],"latent_image":["6",0],"denoise":1.0}},
     "12":{"class_type":"VAEDecode","inputs":{"samples":["11",0],"vae":["3",0]}},
     "13":{"class_type":"SaveImage","inputs":{"images":["12",0],"filename_prefix":"studio_edit"}}}
    return _run(wf,"13")

def gen_text_video(prompt, frames=16):
    frames=max(8,min(32,int(frames)))
    wf={"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"DreamShaper_8_pruned.safetensors"}},
        "10":{"class_type":"ADE_AnimateDiffLoaderGen1","inputs":{"model":["4",0],"model_name":"mm_sd_v15_v2.ckpt","beta_schedule":"autoselect"}},
        "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":prompt+", masterpiece, best quality, highly detailed"}},
        "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":NEG+", static"}},
        "5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":frames}},
        "3":{"class_type":"KSampler","inputs":{"model":["10",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0],"seed":random.randint(1,2**31),"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0}},
        "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},
        "9":{"class_type":"VHS_VideoCombine","inputs":{"images":["8",0],"frame_rate":8.0,"loop_count":0,"filename_prefix":"studio_vid","format":"video/h264-mp4","pingpong":False,"save_output":True}}}
    return _run(wf,"9")

def gen_image_video_prompt(image_b64, prompt, frames=16, strength=0.75):
    """Prompt-guided image->video via AnimateDiff (image as init latent)."""
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    frames=max(8,min(32,int(frames))); strength=max(0.4,min(0.95,float(strength)))
    wf={"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"DreamShaper_8_pruned.safetensors"}},
        "10":{"class_type":"ADE_AnimateDiffLoaderGen1","inputs":{"model":["4",0],"model_name":"mm_sd_v15_v2.ckpt","beta_schedule":"autoselect"}},
        "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":prompt+", masterpiece, best quality, highly detailed"}},
        "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":NEG+", static"}},
        "20":{"class_type":"LoadImage","inputs":{"image":name}},
        "21":{"class_type":"VAEEncode","inputs":{"pixels":["20",0],"vae":["4",2]}},
        "22":{"class_type":"RepeatLatentBatch","inputs":{"samples":["21",0],"amount":frames}},
        "3":{"class_type":"KSampler","inputs":{"model":["10",0],"positive":["6",0],"negative":["7",0],"latent_image":["22",0],"seed":random.randint(1,2**31),"steps":22,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":strength}},
        "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},
        "9":{"class_type":"VHS_VideoCombine","inputs":{"images":["8",0],"frame_rate":8.0,"loop_count":0,"filename_prefix":"studio_ivp","format":"video/h264-mp4","pingpong":False,"save_output":True}}}
    return _run(wf,"9")

def gen_image_video(image_b64, frames=25, motion=127, prompt=""):
    if prompt and prompt.strip():
        return gen_image_video_prompt(image_b64, prompt.strip(), frames)
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    frames=max(14,min(25,int(frames)))
    wf={"15":{"class_type":"ImageOnlyCheckpointLoader","inputs":{"ckpt_name":"svd_xt_1_1.safetensors"}},
        "23":{"class_type":"LoadImage","inputs":{"image":name}},
        "12":{"class_type":"SVD_img2vid_Conditioning","inputs":{"clip_vision":["15",1],"init_image":["23",0],"vae":["15",2],"width":576,"height":576,"video_frames":frames,"motion_bucket_id":int(motion),"fps":6,"augmentation_level":0.0}},
        "22":{"class_type":"VideoLinearCFGGuidance","inputs":{"model":["15",0],"min_cfg":1.0}},
        "3":{"class_type":"KSampler","inputs":{"model":["22",0],"positive":["12",0],"negative":["12",1],"latent_image":["12",2],"seed":random.randint(1,2**31),"steps":20,"cfg":2.5,"sampler_name":"euler","scheduler":"karras","denoise":1.0}},
        "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["15",2]}},
        "9":{"class_type":"VHS_VideoCombine","inputs":{"images":["8",0],"frame_rate":8.0,"loop_count":0,"filename_prefix":"studio_svd","format":"video/h264-mp4","pingpong":False,"save_output":True}}}
    return _run(wf,"9")

def gen_3d(image_b64):
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    wf={"1":{"class_type":"LoadMoGeModel","inputs":{"model_name":"moge_2_vitl_normal_fp16.safetensors"}},
        "2":{"class_type":"LoadImage","inputs":{"image":name}},
        "3":{"class_type":"MoGeInference","inputs":{"moge_model":["1",0],"image":["2",0],"resolution_level":9,"fov_x_degrees":0.0,"batch_size":1,"force_projection":True,"apply_mask":True}},
        "4":{"class_type":"MoGePointMapToMesh","inputs":{"moge_geometry":["3",0],"batch_index":0,"decimation":1,"discontinuity_threshold":0.05,"texture":True}},
        "5":{"class_type":"SaveGLB","inputs":{"mesh":["4",0],"filename_prefix":"studio_3d"}}}
    return _run(wf,"5",keys=("3d","gltf","glb","mesh"))

def gen_hunyuan3d(image_b64, octree=256):
    """Hunyuan3D 2.0 (native): image -> full watertight mesh (GLB), geometry only. ~65s on 8GB."""
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    octree=int(octree); octree=384 if octree>300 else 256
    wf={"1":{"class_type":"ImageOnlyCheckpointLoader","inputs":{"ckpt_name":"hunyuan3d-dit-v2.safetensors"}},
        "2":{"class_type":"LoadImage","inputs":{"image":name}},
        "3":{"class_type":"CLIPVisionEncode","inputs":{"clip_vision":["1",1],"image":["2",0],"crop":"center"}},
        "4":{"class_type":"Hunyuan3Dv2Conditioning","inputs":{"clip_vision_output":["3",0]}},
        "5":{"class_type":"EmptyLatentHunyuan3Dv2","inputs":{"resolution":3072,"batch_size":1}},
        "6":{"class_type":"KSampler","inputs":{"model":["1",0],"seed":random.randint(1,2**31),"steps":30,"cfg":5.0,
             "sampler_name":"euler","scheduler":"simple","positive":["4",0],"negative":["4",1],"latent_image":["5",0],"denoise":1.0}},
        "7":{"class_type":"VAEDecodeHunyuan3D","inputs":{"samples":["6",0],"vae":["1",2],"num_chunks":8000,"octree_resolution":octree}},
        "8":{"class_type":"VoxelToMeshBasic","inputs":{"voxel":["7",0],"threshold":0.6}},
        "9":{"class_type":"SaveGLB","inputs":{"mesh":["8",0],"filename_prefix":"studio_hunyuan3d"}}}
    return _run(wf,"9",keys=("3d","gltf","glb","mesh"))

_CM = os.path.expanduser("~/.ssh/cm-pcctl"); _PC = "YOUR_PC_USER@YOUR_PC_IP"
def _ffmpeg():
    import imageio_ffmpeg; return imageio_ffmpeg.get_ffmpeg_exe()

def gen_removebg_video(video_b64):
    """Video bg removal via RMBG-2.0 per-frame, CHUNKED for 8GB -> transparent WebM + green MP4."""
    FF=_ffmpeg()
    raw=base64.b64decode(video_b64.split(",")[-1])
    srcpath=os.path.join(OUTDIR,"_rmbg_src_%d.mp4"%random.randint(1,999999))
    open(srcpath,"wb").write(raw)
    # probe total frames
    info=subprocess.run([FF,"-i",srcpath],capture_output=True,text=True).stderr
    import re
    fps=24.0; total=0
    fm=re.search(r'([\d.]+) fps',info);  fps=float(fm.group(1)) if fm else 24.0
    dm=re.search(r'Duration: (\d+):(\d+):([\d.]+)',info)
    if dm: total=int((int(dm.group(1))*3600+int(dm.group(2))*60+float(dm.group(3)))*fps)
    vid_name="rmbg_in_%d.mp4"%random.randint(1,999999)
    subprocess.run(["scp","-o","ControlPath="+_CM,srcpath,f"{_PC}:/C:/Users/YOUR_PC_USER/ComfyUI/input/{vid_name}"],check=True,timeout=180)
    CHUNK=24; t0=time.time(); mask_chunks=[]
    starts=list(range(0,max(total,1),CHUNK)) or [0]
    for i,start in enumerate(starts):
        wf={
          "1":{"class_type":"VHS_LoadVideo","inputs":{"video":vid_name,"force_rate":0,"custom_width":0,"custom_height":0,"frame_load_cap":CHUNK,"skip_first_frames":start,"select_every_nth":1}},
          "2":{"class_type":"RMBG","inputs":{"image":["1",0],"model":"RMBG-2.0","sensitivity":1.0,"process_res":512,"mask_blur":0,"mask_offset":0,"invert_output":False,"refine_foreground":False,"background":"Alpha"}},
          "3":{"class_type":"MaskToImage","inputs":{"mask":["2",1]}},
          "9":{"class_type":"VHS_VideoCombine","inputs":{"images":["3",0],"frame_rate":fps,"loop_count":0,"filename_prefix":"rmbg_mask","format":"video/ProRes","pingpong":False,"save_output":True}},
        }
        r=_run(wf,"9")
        if r.get("error"):
            if i==0:
                try: os.remove(srcpath)
                except: pass
                return r
            break  # ran out of frames near the end
        mask_chunks.append(os.path.join(OUTDIR,r["filename"]))
    # concat mask chunks
    maskpath=os.path.join(OUTDIR,"_rmbg_mask_%d.mov"%random.randint(1,999999))
    if len(mask_chunks)==1:
        maskpath=mask_chunks[0]
    else:
        listf=os.path.join(OUTDIR,"_concat.txt"); open(listf,"w").write("".join("file '%s'\n"%m for m in mask_chunks))
        subprocess.run([FF,"-y","-f","concat","-safe","0","-i",listf,"-c","copy",maskpath],capture_output=True,timeout=300)
        try: os.remove(listf)
        except: pass
    tag=random.randint(1,999999)
    webm=os.path.join(OUTDIR,"nobg_%d.webm"%tag)
    mp4 =os.path.join(OUTDIR,"nobg_greenscreen_%d.mp4"%tag)
    # transparent VP9 webm (true alpha)
    subprocess.run([FF,"-y","-i",srcpath,"-i",maskpath,"-filter_complex","[0:v][1:v]alphamerge[o]","-map","[o]","-c:v","libvpx-vp9","-pix_fmt","yuva420p","-b:v","2M",webm],capture_output=True,timeout=600)
    # MP4 on green screen (MP4 can't hold alpha): paint source frame green, overlay subject (dimension-safe)
    subprocess.run([FF,"-y","-i",srcpath,"-i",maskpath,"-filter_complex",
        "[0:v][1:v]alphamerge[fg];[0:v]drawbox=color=0x00b140:t=fill[bg];[bg][fg]overlay[o]",
        "-map","[o]","-c:v","libx264","-pix_fmt","yuv420p",mp4],capture_output=True,timeout=600)
    for f in [srcpath,maskpath]+mask_chunks:
        try: os.remove(f)
        except: pass
    if os.path.exists(webm):
        return {"filename":os.path.basename(webm),"seconds":round(time.time()-t0,1),
                "extra":os.path.basename(mp4) if os.path.exists(mp4) else None}
    return {"error":"ffmpeg alphamerge failed"}

def gen_controlnet(prompt, control_image_b64, control_type="depth", strength=0.8):
    """ControlNet: generate an image that follows the structure of a reference (depth/edges/pose)."""
    models=load_models(); m=models.get("juggernaut") or next(iter(models.values()))
    raw=base64.b64decode(control_image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    cnmap={
      "depth":("DepthAnythingV2Preprocessor",{"image":["10",0]},"depth"),
      "edges":("CannyEdgePreprocessor",{"image":["10",0],"low_threshold":100,"high_threshold":200,"resolution":1024},"canny/lineart/anime_lineart/mlsd"),
      "pose":("OpenposePreprocessor",{"image":["10",0],"detect_hand":"enable","detect_body":"enable","detect_face":"enable","resolution":1024},"openpose"),
    }
    pc,pi,ut=cnmap.get(control_type,cnmap["depth"])
    seed=random.randint(1,2**31); s=max(0.1,min(1.5,float(strength)))
    wf={
      "4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":m["ckpt"]}},
      "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":prompt}},
      "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":NEG}},
      "10":{"class_type":"LoadImage","inputs":{"image":name}},
      "11":{"class_type":pc,"inputs":pi},
      "12":{"class_type":"ControlNetLoader","inputs":{"control_net_name":"controlnet_union_sdxl_promax.safetensors"}},
      "13":{"class_type":"SetUnionControlNetType","inputs":{"control_net":["12",0],"type":ut}},
      "14":{"class_type":"ControlNetApplyAdvanced","inputs":{"positive":["6",0],"negative":["7",0],"control_net":["13",0],"image":["11",0],"strength":s,"start_percent":0.0,"end_percent":1.0,"vae":["4",2]}},
      "5":{"class_type":"EmptyLatentImage","inputs":{"width":1024,"height":1024,"batch_size":1}},
      "3":{"class_type":"KSampler","inputs":{"model":["4",0],"positive":["14",0],"negative":["14",1],"latent_image":["5",0],"seed":seed,"steps":m["steps"],"cfg":m["cfg"],"sampler_name":m["sampler"],"scheduler":m["scheduler"],"denoise":1.0}},
      "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},
      "9":{"class_type":"SaveImage","inputs":{"images":["8",0],"filename_prefix":"controlnet"}},
    }
    return _run(wf,"9")

def gen_transcribe(media_b64):
    """Transcribe audio/video via faster-whisper (medium) on the PC -> text + SRT."""
    raw=base64.b64decode(media_b64.split(",")[-1])
    head=media_b64[:40].lower()
    ext=".mp4" if "video" in head else (".mp3" if "mpeg" in head or "mp3" in head else ".m4a")
    tag=random.randint(1,999999)
    srcpath=os.path.join(OUTDIR,"_ts_%d%s"%(tag,ext))
    open(srcpath,"wb").write(raw)
    pcmedia="ts_%d%s"%(tag,ext)
    subprocess.run(["scp","-o","ControlPath="+_CM,srcpath,f"{_PC}:/C:/Users/YOUR_PC_USER/ComfyUI/input/{pcmedia}"],check=True,timeout=300)
    r=subprocess.run(["ssh","-o","ControlPath="+_CM,"-o","ServerAliveInterval=10",_PC,
        "C:\\Users\\YOUR_PC_USER\\ComfyUI\\venv\\Scripts\\python.exe C:\\Users\\YOUR_PC_USER\\whisper_transcribe.py "
        f"C:\\Users\\YOUR_PC_USER\\ComfyUI\\input\\{pcmedia} C:\\Users\\YOUR_PC_USER\\ts_{tag}.srt C:\\Users\\YOUR_PC_USER\\ts_{tag}.txt"],
        capture_output=True,text=True,timeout=1800)
    srtlocal=os.path.join(OUTDIR,"transcript_%d.srt"%tag)
    txtlocal=os.path.join(OUTDIR,"_ts_%d.txt"%tag)
    subprocess.run(["scp","-o","ControlPath="+_CM,f"{_PC}:/C:/Users/YOUR_PC_USER/ts_{tag}.srt",srtlocal],capture_output=True,timeout=60)
    subprocess.run(["scp","-o","ControlPath="+_CM,f"{_PC}:/C:/Users/YOUR_PC_USER/ts_{tag}.txt",txtlocal],capture_output=True,timeout=60)
    try: os.remove(srcpath)
    except: pass
    text=""
    if os.path.exists(txtlocal):
        text=open(txtlocal,encoding="utf-8").read();
        try: os.remove(txtlocal)
        except: pass
    if os.path.exists(srtlocal) and text:
        return {"text":text,"srt":os.path.basename(srtlocal)}
    return {"error":(r.stderr or r.stdout or "transcription failed")[:300]}

def gen_ipadapter(prompt, ref_image_b64, weight=0.8):
    """IPAdapter: generate an image in the STYLE of a reference image (brand/style consistency)."""
    models=load_models(); m=models.get("juggernaut") or next(iter(models.values()))
    raw=base64.b64decode(ref_image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    seed=random.randint(1,2**31); w=max(0.1,min(1.2,float(weight)))
    wf={
      "4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":m["ckpt"]}},
      "20":{"class_type":"IPAdapterModelLoader","inputs":{"ipadapter_file":"ip-adapter-plus_sdxl_vit-h.safetensors"}},
      "21":{"class_type":"CLIPVisionLoader","inputs":{"clip_name":"CLIP-ViT-H-14-laion2B.safetensors"}},
      "22":{"class_type":"LoadImage","inputs":{"image":name}},
      "23":{"class_type":"IPAdapterAdvanced","inputs":{"model":["4",0],"ipadapter":["20",0],"image":["22",0],"weight":w,"weight_type":"linear","combine_embeds":"concat","start_at":0.0,"end_at":1.0,"embeds_scaling":"V only","clip_vision":["21",0]}},
      "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":prompt}},
      "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":NEG}},
      "5":{"class_type":"EmptyLatentImage","inputs":{"width":1024,"height":1024,"batch_size":1}},
      "3":{"class_type":"KSampler","inputs":{"model":["23",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0],"seed":seed,"steps":m["steps"],"cfg":m["cfg"],"sampler_name":m["sampler"],"scheduler":m["scheduler"],"denoise":1.0}},
      "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},
      "9":{"class_type":"SaveImage","inputs":{"images":["8",0],"filename_prefix":"ipadapter"}},
    }
    return _run(wf,"9")

def gen_inpaint(image_b64, mask_b64, prompt):
    """Inpaint: regenerate the painted (masked) area, guided by a prompt (or remove if empty)."""
    models=load_models(); m=models.get("juggernaut") or next(iter(models.values()))
    img_name=upload_to_comfy(base64.b64decode(image_b64.split(",")[-1]),"inp_img_%d.png"%random.randint(1,999999))
    mask_name=upload_to_comfy(base64.b64decode(mask_b64.split(",")[-1]),"inp_mask_%d.png"%random.randint(1,999999))
    seed=random.randint(1,2**31)
    pos=prompt.strip() if prompt and prompt.strip() else "clean, seamless background, natural continuation"
    wf={
      "4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":m["ckpt"]}},
      "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":pos}},
      "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":NEG}},
      "10":{"class_type":"LoadImage","inputs":{"image":img_name}},
      "11":{"class_type":"LoadImage","inputs":{"image":mask_name}},
      "12":{"class_type":"ImageToMask","inputs":{"image":["11",0],"channel":"red"}},
      "13":{"class_type":"VAEEncodeForInpaint","inputs":{"pixels":["10",0],"vae":["4",2],"mask":["12",0],"grow_mask_by":8}},
      "3":{"class_type":"KSampler","inputs":{"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["13",0],"seed":seed,"steps":max(m["steps"],10),"cfg":m["cfg"],"sampler_name":m["sampler"],"scheduler":m["scheduler"],"denoise":1.0}},
      "8":{"class_type":"VAEDecode","inputs":{"samples":["3",0],"vae":["4",2]}},
      "9":{"class_type":"SaveImage","inputs":{"images":["8",0],"filename_prefix":"inpaint"}},
    }
    return _run(wf,"9")

def gen_removebg(image_b64):
    """Clean image background removal via RMBG-2.0 -> transparent PNG."""
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    wf={
      "1":{"class_type":"LoadImage","inputs":{"image":name}},
      "2":{"class_type":"RMBG","inputs":{"image":["1",0],"model":"RMBG-2.0","sensitivity":1.0,"process_res":1024,"mask_blur":0,"mask_offset":0,"invert_output":False,"refine_foreground":True,"background":"Alpha"}},
      "9":{"class_type":"SaveImage","inputs":{"images":["2",0],"filename_prefix":"nobg"}},
    }
    return _run(wf,"9")

def facefix(name, denoise=None):
    """Detect + regenerate faces in an image at higher detail (FaceDetailer)."""
    safe=os.path.basename(name); fp=os.path.join(OUTDIR,safe)
    if not os.path.isfile(fp): return {"error":"not found"}
    models=load_models(); m=models.get("juggernaut") or next(iter(models.values()))
    up=upload_to_comfy(open(fp,"rb").read())
    seed=random.randint(1,2**31)
    # Scale the detail-render size to the image. On a 4K image the face crop is ~2000px:
    # regenerating it at 512 and upscaling back is what makes faces look waxy/smeared.
    try:
        from PIL import Image as _I
        with _I.open(fp) as _im: _big=max(_im.size)
    except Exception: _big=1024
    # NEVER exceed ~1024: SDXL is trained at 1024 and duplicates features (two noses,
    # extra nostrils) when a face crop is rendered much beyond that. Bigger != better.
    if   _big>=3000: guide,mx = 1024,1152
    elif _big>=1800: guide,mx =  768,1024
    else:            guide,mx =  512,1024
    # Default GENTLE (0.30): higher values redraw the face and change the person's
    # identity (verified on a portrait: 0.45 gave her different brows/eyes entirely).
    # Pass denoise explicitly (e.g. 0.45+) only to repair a genuinely broken face.
    if denoise is None: dn = 0.30
    else:
        try: dn=max(0.1,min(0.8,float(denoise)))
        except Exception: dn=0.45
    wf={
      "4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":m["ckpt"]}},
      "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":"detailed face, sharp focus, realistic skin, high quality"}},
      "7":{"class_type":"CLIPTextEncode","inputs":{"clip":["4",1],"text":NEG}},
      "10":{"class_type":"LoadImage","inputs":{"image":up}},
      "11":{"class_type":"UltralyticsDetectorProvider","inputs":{"model_name":"bbox/face_yolov8m.pt"}},
      "12":{"class_type":"FaceDetailer","inputs":{
        "image":["10",0],"model":["4",0],"clip":["4",1],"vae":["4",2],
        "guide_size":guide,"guide_size_for":True,"max_size":mx,"seed":seed,"steps":max(m["steps"],8),"cfg":m["cfg"],
        "sampler_name":m["sampler"],"scheduler":m["scheduler"],"positive":["6",0],"negative":["7",0],
        "denoise":dn,"feather":5,"noise_mask":True,"force_inpaint":True,
        "bbox_threshold":0.5,"bbox_dilation":10,"bbox_crop_factor":3.0,
        "sam_detection_hint":"center-1","sam_dilation":0,"sam_threshold":0.93,"sam_bbox_expansion":0,
        "sam_mask_hint_threshold":0.7,"sam_mask_hint_use_negative":"False","drop_size":10,
        "bbox_detector":["11",0],"wildcard":"","cycle":1}},
      "9":{"class_type":"SaveImage","inputs":{"images":["12",0],"filename_prefix":"facefix"}},
    }
    return _run(wf,"9")

def upscale(name, factor=4):
    """4x-UltraSharp upscale. factor 2 runs the 4x model then downsamples to 2x -
    sharper than a plain resize, and 2x is the useful step for progressive
    face-detail passes (fix @1024 -> 2x -> fix @2048 -> 2x)."""
    safe=os.path.basename(name); fp=os.path.join(OUTDIR,safe)
    if not os.path.isfile(fp): return {"error":"not found"}
    try: factor=int(factor)
    except Exception: factor=4
    if factor not in (2,4): factor=4
    up=upload_to_comfy(open(fp,"rb").read())
    wf={"10":{"class_type":"LoadImage","inputs":{"image":up}},
        "11":{"class_type":"UpscaleModelLoader","inputs":{"model_name":"4x-UltraSharp.pth"}},
        "12":{"class_type":"ImageUpscaleWithModel","inputs":{"upscale_model":["11",0],"image":["10",0]}}}
    out="12"
    if factor==2:
        try:
            from PIL import Image as _I
            with _I.open(fp) as _im: w,h=_im.size
        except Exception: w=h=1024
        wf["13"]={"class_type":"ImageScale","inputs":{"image":["12",0],"width":w*2,"height":h*2,
                  "upscale_method":"lanczos","crop":"disabled"}}
        out="13"
    wf["9"]={"class_type":"SaveImage","inputs":{"images":[out,0],"filename_prefix":"upscaled"}}
    return _run(wf,"9")

def enhance_face(name, fidelity=0.5, model="codeformer.pth"):
    """CodeFormer / GFPGAN face restoration. Unlike FaceDetailer this is a dedicated
    face-restoration net - it works at ANY resolution and cannot invent extra noses."""
    safe=os.path.basename(name); fp=os.path.join(OUTDIR,safe)
    if not os.path.isfile(fp): return {"error":"not found"}
    try: fid=max(0.0,min(1.0,float(fidelity)))
    except Exception: fid=0.5
    if model not in ("codeformer.pth","GFPGANv1.4.pth"): model="codeformer.pth"
    up=upload_to_comfy(open(fp,"rb").read())
    wf={"10":{"class_type":"LoadImage","inputs":{"image":up}},
        "11":{"class_type":"FaceRestoreModelLoader","inputs":{"model_name":model}},
        "12":{"class_type":"FaceRestoreCFWithModel","inputs":{
              "facerestore_model":["11",0],"image":["10",0],
              "facedetection":"retinaface_resnet50","codeformer_fidelity":fid}},
        "9":{"class_type":"SaveImage","inputs":{"images":["12",0],"filename_prefix":"enhanced"}}}
    return _run(wf,"9")


def interpolate(name, multiplier=2):
    """RIFE frame interpolation: smooth any gallery video 2x/4x."""
    safe=os.path.basename(name); fp=os.path.join(OUTDIR,safe)
    if not os.path.isfile(fp): return {"error":"not found"}
    try: mult=int(multiplier)
    except Exception: mult=2
    if mult not in (2,3,4): mult=2
    FF=_ffmpeg()
    import re as _re
    info=subprocess.run([FF,"-i",fp],capture_output=True,text=True).stderr
    mfps=_re.search(r"(\d+(?:\.\d+)?) fps",info); fps=float(mfps.group(1)) if mfps else 8.0
    vid="interp_in_%d.mp4"%random.randint(1,999999)
    subprocess.run(["scp","-o","ControlMaster=no",fp,"%s:C:/Users/YOUR_PC_USER/ComfyUI/input/%s"%(_PC,vid)],
                   capture_output=True,timeout=240)
    wf={"1":{"class_type":"VHS_LoadVideo","inputs":{"video":vid,"force_rate":0,"custom_width":0,"custom_height":0,
             "frame_load_cap":0,"skip_first_frames":0,"select_every_nth":1}},
        "2":{"class_type":"RIFE VFI","inputs":{"ckpt_name":"rife47.pth","frames":["1",0],"clear_cache_after_n_frames":10,
             "multiplier":mult,"fast_mode":True,"ensemble":True,"scale_factor":1.0,"dtype":"float32",
             "torch_compile":False,"batch_size":1}},
        "9":{"class_type":"VHS_VideoCombine","inputs":{"images":["2",0],"frame_rate":min(60.0,fps*mult),"loop_count":0,
             "filename_prefix":"smooth","format":"video/h264-mp4","pingpong":False,"save_output":True}}}
    return _run(wf,"9")

def gen_relight(image_b64, prompt, direction="none"):
    """IC-Light: relight a subject to match a described scene/lighting."""
    if not image_b64: return {"error":"needs an image"}
    dirmap={"left":"light coming from the left side, ","right":"light coming from the right side, ",
            "top":"light from above, ","bottom":"light from below, ","none":""}
    full=dirmap.get(direction,"")+ (prompt or "soft studio lighting") + ", best quality, detailed"
    raw=base64.b64decode(image_b64.split(",")[-1])
    # SD1.5 works best <=768; resize on the Mac, dims /8
    import io as _io
    from PIL import Image as _I
    im=_I.open(_io.BytesIO(raw)).convert("RGB")
    sc=768/max(im.size); w=int(im.width*sc)//8*8; h=int(im.height*sc)//8*8
    im=im.resize((max(w,64),max(h,64)),_I.LANCZOS)
    buf=_io.BytesIO(); im.save(buf,"PNG"); name=upload_to_comfy(buf.getvalue())
    wf={"1":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"DreamShaper_8_pruned.safetensors"}},
        "2":{"class_type":"LoadAndApplyICLightUnet","inputs":{"model":["1",0],"model_path":"iclight_sd15_fc.safetensors"}},
        "3":{"class_type":"LoadImage","inputs":{"image":name}},
        "4":{"class_type":"VAEEncode","inputs":{"pixels":["3",0],"vae":["1",2]}},
        "5":{"class_type":"CLIPTextEncode","inputs":{"clip":["1",1],"text":full}},
        "6":{"class_type":"CLIPTextEncode","inputs":{"clip":["1",1],"text":"lowres, bad quality, watermark, text"}},
        "7":{"class_type":"ICLightConditioning","inputs":{"positive":["5",0],"negative":["6",0],"vae":["1",2],
             "foreground":["4",0],"multiplier":0.18215}},
        "8":{"class_type":"KSampler","inputs":{"model":["2",0],"seed":random.randint(1,2**31),"steps":25,"cfg":2.0,
             "sampler_name":"dpmpp_2m","scheduler":"sgm_uniform","positive":["7",0],"negative":["7",1],
             "latent_image":["7",2],"denoise":0.9}},
        "9":{"class_type":"VAEDecode","inputs":{"samples":["8",0],"vae":["1",2]}},
        "10":{"class_type":"SaveImage","inputs":{"images":["9",0],"filename_prefix":"studio_relight"}}}
    return _run(wf,"10")

def gen_describe(name=None, image_b64=None):
    """Florence-2: caption a gallery image OR an uploaded image into a reusable prompt."""
    if image_b64:
        raw=base64.b64decode(image_b64.split(",")[-1]); up=upload_to_comfy(raw)
    else:
        safe=os.path.basename(name or ""); fp=os.path.join(OUTDIR,safe)
        if not os.path.isfile(fp): return {"error":"not found"}
        up=upload_to_comfy(open(fp,"rb").read())
    wf={"1":{"class_type":"DownloadAndLoadFlorence2Model","inputs":{"model":"microsoft/Florence-2-large","precision":"fp16"}},
        "2":{"class_type":"LoadImage","inputs":{"image":up}},
        "3":{"class_type":"Florence2Run","inputs":{"image":["2",0],"florence2_model":["1",0],"text_input":"",
             "task":"more_detailed_caption","fill_mask":False,"keep_model_loaded":False,"max_new_tokens":512,
             "num_beams":3,"do_sample":False,"output_mask_select":"","seed":1}},
        "9":{"class_type":"PreviewAny","inputs":{"source":["3",2]}}}
    _cancel.clear()
    t0=time.time(); pid=cpost("/prompt",{"prompt":wf})["prompt_id"]
    for _ in range(300):
        if _cancel.is_set(): return {"error":"cancelled"}
        time.sleep(1.2); hist=cget("/history/"+pid)
        if pid in hist:
            o=hist[pid].get("outputs",{})
            if "9" in o:
                t=o["9"].get("text") or o["9"].get("value") or []
                if t: return {"text":str(t[0]).strip(),"seconds":round(time.time()-t0,1)}
            st=hist[pid].get("status",{})
            if st.get("status_str")=="error": return {"error":json.dumps(st.get("messages",st))[:400]}
    return {"error":"timeout"}

def gen_segment(image_b64, points):
    """SAM2: click-points -> object mask (for the Inpaint smart-select)."""
    if not image_b64 or not points: return {"error":"needs image + points"}
    raw=base64.b64decode(image_b64.split(",")[-1]); name=upload_to_comfy(raw)
    coords=json.dumps([{"x":int(p[0]),"y":int(p[1])} for p in points])
    wf={"1":{"class_type":"DownloadAndLoadSAM2Model","inputs":{"model":"sam2.1_hiera_base_plus.safetensors",
             "segmentor":"single_image","device":"cuda","precision":"fp16"}},
        "2":{"class_type":"LoadImage","inputs":{"image":name}},
        "3":{"class_type":"Sam2Segmentation","inputs":{"sam2_model":["1",0],"image":["2",0],
             "keep_model_loaded":False,"coordinates_positive":coords,"individual_objects":False}},
        "4":{"class_type":"MaskToImage","inputs":{"mask":["3",0]}},
        "9":{"class_type":"SaveImage","inputs":{"images":["4",0],"filename_prefix":"studio_mask"}}}
    return _run(wf,"9")


def gallery(limit=80):
    fs=[f for f in os.listdir(OUTDIR) if f.lower().endswith((".png",".jpg",".gif",".mp4",".glb",".mp3",".wav"))]
    fs.sort(key=lambda f:os.path.getmtime(os.path.join(OUTDIR,f)),reverse=True)
    return fs[:limit]

def delete_file(name):
    safe=os.path.basename(name); fp=os.path.join(OUTDIR,safe)
    if os.path.isfile(fp) and os.path.dirname(os.path.abspath(fp))==os.path.abspath(OUTDIR):
        os.remove(fp)
        try:
            with _meta_lock:
                m=load_meta()
                if safe in m:
                    del m[safe]
                    tmp=META+".tmp"
                    with open(tmp,"w") as f: json.dump(m,f)
                    os.replace(tmp,META)
        except Exception: pass
        return {"ok":True}
    return {"ok":False,"error":"not found"}

MIME={".png":"image/png",".jpg":"image/jpeg",".gif":"image/gif",".mp4":"video/mp4",".glb":"model/gltf-binary",".mp3":"audio/mpeg",".wav":"audio/wav"}

HTML = open(os.path.join(os.path.dirname(os.path.abspath(__file__)),"studio.html")).read() if os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)),"studio.html")) else "STUDIO_HTML_PLACEHOLDER"

class H(http.server.BaseHTTPRequestHandler):
    def log_message(self,*a): pass
    def _send(self,body,ctype,status=200):
        self.send_response(status); self.send_header("Content-Type",ctype); self.send_header("Content-Length",str(len(body)))
        # the app page and API must never be cached - stale HTML after an update looks like a broken app
        if "html" in ctype or "json" in ctype: self.send_header("Cache-Control","no-store")
        self.end_headers(); self.wfile.write(body)
    def do_GET(self):
        if self.path.startswith("/img/"):
            name=os.path.basename(self.path.split("?")[0][5:]); fp=os.path.join(OUTDIR,name)
            if os.path.isfile(fp):
                ext=os.path.splitext(name)[1].lower()
                with open(fp,"rb") as f: self._send(f.read(),MIME.get(ext,"application/octet-stream"))
            else: self._send(b"not found","text/plain",404)
        elif self.path.startswith("/api/gallery"): self._send(json.dumps(gallery()).encode(),"application/json")
        elif self.path.startswith("/api/meta"): self._send(json.dumps(load_meta()).encode(),"application/json")
        elif self.path.startswith("/api/loras"):
            try: lst=cget("/object_info/LoraLoader")["LoraLoader"]["input"]["required"]["lora_name"][0]
            except Exception: lst=[]
            self._send(json.dumps(lst).encode(),"application/json")
        elif self.path.startswith("/api/models"):
            # models flagged "pending" (e.g. still downloading) are hidden from the picker
            self._send(json.dumps([{"key":k,"label":v.get("label",k)} for k,v in load_models().items()
                                   if not v.get("pending")]).encode(),"application/json")
        else: self._send(HTML.encode(),"text/html; charset=utf-8")
    def do_POST(self):
        n=int(self.headers.get("Content-Length",0) or 0)
        try: b=json.loads(self.rfile.read(n) or b"{}")
        except: b={}
        p=self.path
        try:
            # NOTE: cancel must be handled WITHOUT _lock - a running job holds it,
            # so taking the lock here would deadlock until the job we want to kill finishes.
            if p=="/api/cancel":
                r=cancel_job()
            elif p=="/api/image":
                mk=b.get("model","juggernaut"); mc=load_models().get(mk,{})
                if mc.get("engine")=="flux" and not b.get("image"):
                    with _lock: r=gen_flux(str(b.get("prompt",""))[:2000],b.get("aspect","square"))
                else:
                    with _lock: r=gen_image(str(b.get("prompt",""))[:2000],mk,b.get("aspect","square"),b.get("image"),b.get("strength",0.65),str(b.get("negative",""))[:1000],str(b.get("lora","")),b.get("lora_strength",0.8))
            elif p=="/api/music":
                with _lock: r=gen_music(str(b.get("tags",""))[:600],str(b.get("lyrics",""))[:3000],b.get("seconds",30))
            elif p=="/api/tts":
                with _lock: r=gen_tts(str(b.get("text",""))[:4000],b.get("voice","hfc_female"))
            elif p=="/api/ltx_video":
                with _lock: r=gen_ltx_video(str(b.get("prompt",""))[:2000],b.get("image"),b.get("seconds",4),str(b.get("negative",""))[:500])
            elif p=="/api/kontext":
                with _lock: r=gen_kontext(str(b.get("prompt",""))[:2000],b.get("image"))
            elif p=="/api/text_video":
                with _lock: r=gen_text_video(str(b.get("prompt",""))[:2000],b.get("frames",16))
            elif p=="/api/image_video":
                with _lock: r=gen_image_video(b.get("image"),b.get("frames",25),b.get("motion",127),str(b.get("prompt","")))
            elif p=="/api/threed":
                with _lock:
                    if b.get("method")=="hunyuan3d": r=gen_hunyuan3d(b.get("image"),b.get("octree",256))
                    else: r=gen_3d(b.get("image"))
            elif p=="/api/controlnet":
                with _lock: r=gen_controlnet(str(b.get("prompt",""))[:2000],b.get("image"),b.get("control_type","depth"),b.get("strength",0.8))
            elif p=="/api/ipadapter":
                with _lock: r=gen_ipadapter(str(b.get("prompt",""))[:2000],b.get("image"),b.get("strength",0.8))
            elif p=="/api/inpaint":
                with _lock: r=gen_inpaint(b.get("image"),b.get("mask"),str(b.get("prompt","")))
            elif p=="/api/removebg":
                with _lock: r=gen_removebg(b.get("image"))
            elif p=="/api/removebg_video":
                with _lock: r=gen_removebg_video(b.get("video"))
            elif p=="/api/transcribe":
                with _lock: r=gen_transcribe(b.get("media"))
            elif p=="/api/upscale":
                with _lock: r=upscale(str(b.get("filename","")),b.get("factor",4))
            elif p=="/api/facefix":
                with _lock: r=facefix(str(b.get("filename","")),b.get("denoise"))
            elif p=="/api/interpolate":
                with _lock: r=interpolate(str(b.get("filename","")),b.get("multiplier",2))
            elif p=="/api/relight":
                with _lock: r=gen_relight(b.get("image"),str(b.get("prompt",""))[:1000],str(b.get("direction","none")))
            elif p=="/api/describe":
                with _lock: r=gen_describe(str(b.get("filename","")) or None, b.get("image"))
            elif p=="/api/segment":
                with _lock: r=gen_segment(b.get("image"),b.get("points") or [])
            elif p=="/api/enhanceface":
                with _lock: r=enhance_face(str(b.get("filename","")),b.get("fidelity",0.5),b.get("model","codeformer.pth"))
            elif p=="/api/delete": r=delete_file(str(b.get("filename","")))
            else: r={"error":"not found"}
        except Exception as e:
            # never drop the connection - report the failure so the UI can show it
            r={"error": type(e).__name__+": "+str(e)[:300]}
        # remember how each output was made, so the UI can show / reuse the prompt
        if isinstance(r,dict) and r.get("filename"): save_meta(r["filename"],p,b)
        self._send(json.dumps(r).encode(),"application/json")

class Srv(socketserver.ThreadingTCPServer): allow_reuse_address=True; daemon_threads=True
if __name__=="__main__":
    with Srv(("0.0.0.0",PORT),H) as httpd:
        print("Unified AI Studio on http://localhost:%d"%PORT); httpd.serve_forever()
```

## Appendix B — `studio.html` (the interface)

> Save as `~/pc-ai/scripts/studio.html` next to `studio.py`. No edits needed.

```html
<!doctype html><html><head><meta charset=utf-8><title>AI Studio</title>
<meta name=viewport content="width=device-width,initial-scale=1">
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/3.5.0/model-viewer.min.js"></script>
<style>
:root{--bg:#0b0e14;--card:#151a23;--bd:#262d3a;--tx:#e7edf5;--mut:#8a94a6;--acc:#6ea8fe;--acc2:#a06eff}
*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--tx)}
.wrap{max-width:920px;margin:0 auto;padding:22px 18px 70px}
h1{font-size:21px;margin:0 0 2px;background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.sub{color:var(--mut);font-size:13px;margin-bottom:16px}
.tabs{display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap}
.tabs button{background:var(--card);border:1px solid var(--bd);color:var(--mut);border-radius:11px;padding:9px 18px;cursor:pointer;font:14px inherit;font-weight:600}
.tabs button.on{background:linear-gradient(90deg,var(--acc),var(--acc2));color:#08111f;border-color:transparent}
.panel{background:var(--card);border:1px solid var(--bd);border-radius:16px;padding:16px}
textarea{width:100%;background:#0d1119;border:1px solid var(--bd);border-radius:12px;color:var(--tx);padding:13px;font:15px inherit;resize:vertical;min-height:74px}
textarea:focus,select:focus{outline:none;border-color:var(--acc)}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px}
select{background:#0d1119;border:1px solid var(--bd);border-radius:10px;color:var(--tx);padding:9px 12px;font:14px inherit}
.seg{display:inline-flex;border:1px solid var(--bd);border-radius:10px;overflow:hidden}
.seg button{background:none;border:0;color:var(--mut);padding:9px 14px;cursor:pointer;font:14px inherit}
.seg button.on{background:var(--acc);color:#04121f;font-weight:700}
.gen{margin-left:auto;background:linear-gradient(90deg,var(--acc),var(--acc2));color:#08111f;border:0;border-radius:11px;padding:11px 26px;font-weight:800;font-size:15px;cursor:pointer}
.gen:disabled{opacity:.5;cursor:default}
.up{background:#0d1119;border:1px dashed var(--bd);border-radius:10px;color:var(--mut);padding:9px 14px;cursor:pointer;font-size:14px}
.up:hover{border-color:var(--acc);color:var(--tx)}
.thumb{height:44px;border-radius:8px;border:1px solid var(--bd)}
input[type=range]{accent-color:var(--acc)}
.hint{color:var(--mut);font-size:12.5px;margin-top:10px}
.stage{margin-top:16px}
.result{border:1px solid var(--bd);border-radius:14px;overflow:hidden;background:#0d1119}
.result img,.result video,.result model-viewer{width:100%;display:block;max-height:70vh}
model-viewer{width:100%;height:460px;background:#0d1119}
.meta{display:flex;gap:14px;color:var(--mut);font-size:12px;padding:10px 14px;flex-wrap:wrap;align-items:center}
.meta a{color:var(--acc);text-decoration:none}
.loading{display:flex;align-items:center;gap:12px;color:var(--mut);padding:34px;justify-content:center}
.spin{width:22px;height:22px;border:3px solid #2a3547;border-top-color:var(--acc);border-radius:50%;animation:s .8s linear infinite}
@keyframes s{to{transform:rotate(360deg)}}
.err{color:#ff8a8a;padding:14px;background:#2a1618;border-radius:12px}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--mut);margin:26px 0 12px;display:flex;justify-content:space-between;align-items:center}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(152px,1fr));gap:10px}
.cell{position:relative;border-radius:10px;overflow:hidden;border:1px solid var(--bd);aspect-ratio:1;background:#0d1119}
.cell img,.cell video{width:100%;height:100%;object-fit:cover;display:block}
.badge{position:absolute;top:6px;left:6px;background:rgba(10,14,20,.85);border:1px solid var(--bd);border-radius:6px;font-size:10px;padding:2px 6px;color:var(--acc)}
.ov{position:absolute;inset:0;background:linear-gradient(180deg,transparent 40%,rgba(0,0,0,.82));opacity:0;transition:.15s;display:flex;flex-wrap:wrap;align-items:flex-end;align-content:flex-end;justify-content:flex-end;gap:4px;padding:6px}
.cell:hover .ov{opacity:1}
.ov button{background:rgba(20,26,35,.92);border:1px solid var(--bd);color:var(--tx);width:26px;height:26px;border-radius:7px;cursor:pointer;font-size:12px;display:inline-flex;align-items:center;justify-content:center;padding:0}
.ov button.del:hover{border-color:#ff6b6b;color:#ff6b6b}
.ov button.xbtn{font-size:10.5px;font-weight:700;letter-spacing:-.2px}
.clearall{background:none;border:1px solid var(--bd);color:var(--mut);border-radius:8px;padding:5px 12px;cursor:pointer;font-size:12px}
.clearall:hover{border-color:#ff6b6b;color:#ff6b6b}
.hidden{display:none}
/* modern stroke icons */
.ic{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;flex:none}
.tabs button{display:inline-flex;align-items:center;gap:7px}
.tabs button .ic{width:15px;height:15px}
.ov button .ic{width:13px;height:13px}
.up{display:inline-flex;align-items:center;gap:8px}
/* two-column: controls left, preview stays visible on the right */
.cols{display:grid;grid-template-columns:1fr;gap:16px;align-items:start}
@media(min-width:1080px){
  .wrap{max-width:1360px}
  .cols{grid-template-columns:minmax(0,1fr) minmax(0,540px)}
  .side{position:sticky;top:18px}
}
.stage:empty{display:none}
.ph{border:1px dashed var(--bd);border-radius:14px;color:var(--mut);padding:40px 20px;text-align:center;font-size:13px}
/* prompt reveal */
.pbtn{background:none;border:1px solid var(--bd);color:var(--mut);border-radius:7px;padding:3px 9px;cursor:pointer;font:12px inherit}
.pbtn:hover{border-color:var(--acc);color:var(--acc)}
.pbox{padding:0 14px 12px;font-size:12.5px;color:var(--tx);line-height:1.5}
.pbox .lbl{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.4px;margin-top:8px}
.pbox .val{background:#0d1119;border:1px solid var(--bd);border-radius:8px;padding:8px 10px;margin-top:3px;white-space:pre-wrap;word-break:break-word}
.prow{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}
/* gallery picker modal */
#picker{position:fixed;inset:0;background:rgba(4,7,12,.82);z-index:60;display:flex;align-items:center;justify-content:center}
#picker.hidden{display:none}
#picker .pkbody{background:var(--card);border:1px solid var(--bd);border-radius:16px;max-width:860px;width:94%;max-height:82vh;overflow:auto;padding:16px}
#picker h3{margin:0 0 12px;font-size:14px;color:var(--mut);display:flex;justify-content:space-between;align-items:center}
#pickgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px}
#pickgrid .cell{cursor:pointer}
#pickgrid .cell:hover{border-color:var(--acc)}
</style></head><body>
<svg style="display:none">
<symbol id=i-image viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></symbol>
<symbol id=i-video viewBox="0 0 24 24"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></symbol>
<symbol id=i-target viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5"/></symbol>
<symbol id=i-brush viewBox="0 0 24 24"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 114.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.21 0 4-1.8 4-4.04a3.01 3.01 0 00-3-3.02z"/></symbol>
<symbol id=i-box viewBox="0 0 24 24"><path d="M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05M12 22.08V12"/></symbol>
<symbol id=i-music viewBox="0 0 24 24"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></symbol>
<symbol id=i-scissors viewBox="0 0 24 24"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4L8.12 15.88M14.47 14.48L20 20M8.12 8.12L12 12"/></symbol>
<symbol id=i-mic viewBox="0 0 24 24"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2M12 19v4M8 23h8"/></symbol>
<symbol id=i-grid viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></symbol>
<symbol id=i-camera viewBox="0 0 24 24"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></symbol>
<symbol id=i-download viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></symbol>
<symbol id=i-trash viewBox="0 0 24 24"><path d="M3 6h18M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6M10 11v6M14 11v6"/></symbol>
<symbol id=i-expand viewBox="0 0 24 24"><path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/></symbol>
<symbol id=i-up viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7"/></symbol>
<symbol id=i-smile viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><path d="M9 9h.01M15 9h.01"/></symbol>
<symbol id=i-clock viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></symbol>
<symbol id=i-copy viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></symbol>
<symbol id=i-text viewBox="0 0 24 24"><path d="M4 7V4h16v3M9 20h6M12 4v16"/></symbol>
<symbol id=i-wand viewBox="0 0 24 24"><path d="M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8L19 13M15 9h0M17.8 6.2L19 5M3 21l9-9M12.2 6.2L11 5"/></symbol>
<symbol id=i-reuse viewBox="0 0 24 24"><path d="M3 2v6h6"/><path d="M3.51 15a9 9 0 102.13-9.36L3 8"/></symbol>
</svg>
<div class=wrap>
<h1>&#10024; AI Studio</h1><div class=sub>Everything on your GPU &middot; local &amp; free</div>
<div class=tabs>
 <button data-t=image class=on onclick=tab('image')><svg class=ic><use href=#i-image></use></svg>Image</button>
 <button data-t=video onclick=tab('video')><svg class=ic><use href=#i-video></use></svg>Video</button>
 <button data-t=control onclick=tab('control')><svg class=ic><use href=#i-target></use></svg>Control</button>
 <button data-t=inpaint onclick=tab('inpaint')><svg class=ic><use href=#i-brush></use></svg>Inpaint</button>
 <button data-t=threed onclick=tab('threed')><svg class=ic><use href=#i-box></use></svg>3D</button>
 <button data-t=audio onclick=tab('audio')><svg class=ic><use href=#i-music></use></svg>Audio</button>
 <button data-t=removebg onclick=tab('removebg')><svg class=ic><use href=#i-scissors></use></svg>Remove BG</button>
 <button data-t=transcribe onclick=tab('transcribe')><svg class=ic><use href=#i-mic></use></svg>Transcribe</button>
 <button data-t=gallery onclick=tab('gallery')><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
</div>

<div class=cols><div class=main>
<!-- IMAGE -->
<div id=t-image class=panel>
 <textarea id=iprompt placeholder="Describe the image... e.g. a glowing jellyfish in the deep sea, cinematic"></textarea>
 <input id=inegative type=text placeholder="Negative - what to avoid (optional). e.g. blurry, extra fingers, harsh shadows" style="width:100%;margin-top:8px">
 <div id=inegnote class="hint hidden" style="margin-top:6px">Flux ignores negative prompts (it runs guidance-distilled at CFG 1) - describe what you want instead.</div>
 <div class=row>
   <label class=up id=iuplabel><svg class=ic><use href=#i-camera></use></svg>Source image (optional)<input type=file id=ifile accept="image/*" class=hidden></label>
   <button class=up onclick="openPicker('image',['img'])"><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
   <div id=ibox class="row hidden" style=margin:0>
     <img id=ithumb class=thumb><span class=mut style=font-size:13px>strength</span>
     <input type=range id=istrength min=20 max=95 value=65><span id=istrval class=mut style=font-size:12px>0.65</span>
     <button class=pbtn id=idesc onclick=describeSrc()>Aa Describe</button>
     <button onclick=clearSrc() style="background:none;border:0;color:var(--mut);cursor:pointer;font-size:16px">&times;</button>
   </div>
 </div>
 <div class=row>
   <select id=imodel></select>
   <select id=ilora class=hidden title="LoRA style pack"><option value="">No LoRA</option></select>
   <span id=ilorastrwrap class=hidden><span class=mut style=font-size:12px>lora</span> <input type=range id=ilorastr min=10 max=120 value=80 style=width:70px><span id=ilorastrval class=mut style=font-size:11px>0.8</span></span>
   <div class=seg id=iaspect>
     <button data-a=square class=on title=Square><svg class=ic viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></svg></button>
     <button data-a=portrait title=Portrait><svg class=ic viewBox="0 0 24 24"><rect x="7" y="3" width="10" height="18" rx="2"/></svg></button>
     <button data-a=landscape title=Landscape><svg class=ic viewBox="0 0 24 24"><rect x="3" y="7" width="18" height="10" rx="2"/></svg></button>
   </div>
   <button class=gen id=igo onclick=goImage()>Generate</button>
 </div>
 <div class=hint>Add a source image to transform it (image-to-image). Cmd+Enter to generate.</div>
</div>

<!-- VIDEO -->
<div id=t-video class="panel hidden">
 <div class=row style=margin:0>
  <div class=seg id=vmode style=margin-bottom:12px><button data-m=text class=on>Text &rarr; Video</button><button data-m=image>Image &rarr; Video</button></div>
  <div class=seg id=vengine style=margin-bottom:12px><button data-e=ltx class=on>LTX (new)</button><button data-e=classic>Classic</button></div>
 </div>
 <div id=vtext>
   <textarea id=vprompt placeholder="Describe the motion... e.g. ocean waves crashing on rocks, cinematic"></textarea>
 </div>
 <div id=vimage class=hidden>
   <label class=up id=vuplabel><svg class=ic><use href=#i-camera></use></svg>Upload image to animate<input type=file id=vfile accept="image/*" class=hidden></label>
   <button class=up onclick="openPicker('video',['img'])"><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
   <img id=vthumb class="thumb hidden" style="height:70px;margin:10px 0">
   <textarea id=viprompt placeholder="Optional: describe the motion to guide it (leave empty for natural SVD motion)"></textarea>
 </div>
 <div class=row>
   <label class=mut id=vsecwrap style=font-size:13px>Length <select id=vsecs><option value=2>2s</option><option value=3>3s</option><option value=4 selected>4s</option><option value=5>5s</option><option value=6>6s</option></select></label>
   <span class=mut id=vframelbl style="font-size:13px;display:none">Frames</span>
   <input type=range id=vframes min=14 max=25 value=16 style=display:none><span id=vframeval class=mut style="font-size:12px;display:none">16</span>
   <button class=gen id=vgo onclick=goVideo()>Generate</button>
 </div>
 <div class=hint id=vhint>LTX-Video: modern, fast, 24fps at 768x512 (~40-90s). Classic = AnimateDiff / SVD (the old engines).</div>
</div>

<!-- INPAINT -->
<div id=t-inpaint class="panel hidden">
 <textarea id=inprompt placeholder="What to put in the painted area (leave empty to remove/clean)"></textarea>
 <div class=row>
   <label class=up id=inuplabel><svg class=ic><use href=#i-camera></use></svg>Upload image to edit<input type=file id=infile accept="image/*" class=hidden></label>
   <button class=up onclick="openPicker('inpaint',['img'])"><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
   <span class="mut hidden" id=inbrushlbl style=font-size:13px>Brush</span>
   <input type=range id=inbrush min=10 max=90 value=40 class=hidden style=width:100px>
   <button class=up id=inclear onclick=clearMask() style="display:none">Clear mask</button>
   <button class=up id=insmart onclick=toggleSmart() style="display:none">&#10024; Smart select</button>
 </div>
 <div id=incanvaswrap style="display:none;margin-top:12px;position:relative;width:fit-content;border:1px solid var(--bd);border-radius:10px;overflow:hidden;touch-action:none">
   <canvas id=incanvas style="display:block;max-width:100%;cursor:crosshair"></canvas>
 </div>
 <div class=row><button class=gen id=ingo onclick=goInpaint()>Generate</button></div>
 <div class=hint>Paint over what you want to change or remove, then Generate. Great for removing objects, swapping items, fixing spots. (~10s)</div>
</div>

<!-- CONTROL -->
<div id=t-control class="panel hidden">
 <textarea id=cprompt placeholder="Describe what to generate... it'll follow the structure of your reference image"></textarea>
 <div class=row>
   <label class=up id=cuplabel><svg class=ic><use href=#i-camera></use></svg>Reference image<input type=file id=cfile accept="image/*" class=hidden></label>
   <button class=up onclick="openPicker('control',['img'])"><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
   <img id=cthumb class="thumb hidden">
 </div>
 <div class=row>
   <span class=mut style=font-size:13px>Control</span>
   <div class=seg id=ctype><button data-c=depth class=on>Depth</button><button data-c=edges>Edges</button><button data-c=pose>Pose</button><button data-c=style>Style</button><button data-c=edit>Edit</button><button data-c=relight>Relight</button></div>
   <select id=cdir class=hidden><option value=none>Light: auto</option><option value=left>Light: left</option><option value=right>Light: right</option><option value=top>Light: above</option><option value=bottom>Light: below</option></select>
   <span class=mut style=font-size:13px>Strength</span><input type=range id=cstrength min=30 max=120 value=85><span id=cstrval class=mut style=font-size:12px>0.85</span>
   <button class=gen id=cgo onclick=goControl()>Generate</button>
 </div>
 <div class=hint>Depth = keep the 3D layout. Edges = keep outlines/composition. Pose = copy a person's pose. Great for restyling scenes or directing a shot. (~15s)</div>
</div>

<!-- 3D -->
<div id=t-threed class="panel hidden">
 <label class=up id=duplabel><svg class=ic><use href=#i-camera></use></svg>Upload a photo to turn into 3D<input type=file id=dfile accept="image/*" class=hidden></label>
 <button class=up onclick="openPicker('threed',['img'])"><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
 <img id=dthumb class="thumb hidden" style="height:90px;margin:12px 0">
 <div class=row>
  <select id=dmethod>
   <option value=hunyuan3d>Hunyuan3D - full solid object (~65s)</option>
   <option value=moge>MoGe - scene / room depth (~20s)</option>
  </select>
  <button class=gen id=dgo onclick=go3D()>Generate 3D</button>
 </div>
 <div class=hint><b>Hunyuan3D</b>: a single object/character/product photo -> a full watertight 3D model you can spin all the way around (geometry only, no color). <b>MoGe</b>: a room, building, or scene photo -> textured depth mesh (front-facing). Drag to rotate the result.</div>
</div>

<!-- AUDIO -->
<div id=t-audio class="panel hidden">
 <div class=seg id=amode style="margin-bottom:12px"><button data-am=music class=on><svg class=ic><use href=#i-music></use></svg> Music</button><button data-am=speech><svg class=ic><use href=#i-mic></use></svg> Speech</button></div>
 <div id=au-music>
  <input id=atags type=text placeholder="Style / genre tags, e.g. lofi hip hop, mellow, jazzy piano, 80 bpm" style="width:100%">
  <textarea id=alyrics placeholder="Lyrics (optional - leave empty for instrumental). Use [verse] [chorus] tags for structure." style="height:90px;margin-top:8px"></textarea>
  <div class=row style="margin-top:8px">
   <label class=mut>Length <select id=asecs><option value=15>15s</option><option value=30 selected>30s</option><option value=60>60s</option><option value=90>90s</option><option value=120>2 min</option></select></label>
   <button class=gen id=amgo onclick=goMusic()>Generate Music</button>
  </div>
  <div class=hint>ACE-Step 3.5B makes full tracks (with vocals if you add lyrics) from style tags. Generation takes roughly 1-2s per second of audio.</div>
 </div>
 <div id=au-speech class=hidden>
  <textarea id=attext placeholder="Type what you want spoken aloud..." style="height:110px"></textarea>
  <div class=row style="margin-top:8px">
   <label class=mut>Voice <select id=avoice>
    <optgroup label="Kokoro (natural)">
     <option value=af_heart selected>Heart (F)</option><option value=af_bella>Bella (F)</option>
     <option value=af_sarah>Sarah (F)</option><option value=am_adam>Adam (M)</option>
     <option value=am_michael>Michael (M)</option><option value=bf_emma>Emma (UK F)</option>
     <option value=bm_george>George (UK M)</option>
    </optgroup>
    <optgroup label="Piper (fast)">
     <option value=hfc_female>Female (US)</option><option value=ryan>Male (US)</option>
    </optgroup>
   </select></label>
   <button class=gen id=atgo onclick=goTTS()>Speak</button>
  </div>
  <div class=hint>Local text-to-speech via Piper. Fast, runs on the PC, no internet.</div>
 </div>
</div>

<!-- REMOVE BG -->
<div id=t-removebg class="panel hidden">
 <label class=up id=ruplabel><svg class=ic><use href=#i-camera></use></svg>Upload a photo or video to remove its background<input type=file id=rfile accept="image/*,video/*" class=hidden></label>
 <button class=up onclick="openPicker('removebg',['img','mp4'])"><svg class=ic><use href=#i-grid></use></svg>Gallery</button>
 <img id=rthumb class="thumb hidden" style="height:90px;margin:12px 0">
 <div id=rvid class="mut hidden" style="margin:12px 0;font-size:13px"><svg class=ic><use href=#i-video></use></svg> video loaded</div>
 <div class=row><button class=gen id=rgo onclick=goRemoveBg()>Remove Background</button></div>
 <div class=hint>Photo &rarr; RMBG-2.0, transparent PNG (~4s). Video &rarr; RVM, transparent WebM + green-screen MP4 (~1min). MP4 can't be transparent, so it gets a green background you can key out.</div>
</div>

<!-- TRANSCRIBE -->
<div id=t-transcribe class="panel hidden">
 <label class=up id=tuplabel><svg class=ic><use href=#i-mic></use></svg> Upload audio or video to transcribe<input type=file id=tfile accept="audio/*,video/*" class=hidden></label>
 <div id=tname class="mut hidden" style="margin:12px 0;font-size:13px"></div>
 <div class=row><button class=gen id=tgo onclick=goTranscribe()>Transcribe</button></div>
 <div class=hint>Whisper (medium) - speech to text + subtitles (SRT). Any language, auto-detected. Runs on your GPU. (~realtime)</div>
</div>

</div><div class=side>
<div class=stage id=stage></div>
<div class=ph id=stageph>Your result will appear here</div>
</div></div>
<div id=picker class=hidden onclick="if(event.target===this)closePicker()">
 <div class=pkbody>
  <h3>Pick from your gallery <button class=clearall onclick=closePicker()>close</button></h3>
  <div id=pickgrid></div>
 </div>
</div>
<h2>Gallery <button class=clearall onclick=clearAll()>Clear all</button></h2>
<div class=grid id=grid></div>

<script>
let cur='image';
function tab(t){cur=t;['image','video','control','inpaint','threed','audio','removebg','transcribe','gallery'].forEach(x=>{document.getElementById('t-'+x)&&document.getElementById('t-'+x).classList.toggle('hidden',x!==t);});
 document.querySelectorAll('.tabs button').forEach(b=>b.classList.toggle('on',b.dataset.t===t));
 document.getElementById('stage').innerHTML='';if(t==='gallery')document.getElementById('stage').scrollIntoView();loadGrid();}
// image tab
let iaspect='square',isrc=null;
document.querySelectorAll('#iaspect button').forEach(b=>b.onclick=()=>{document.querySelectorAll('#iaspect button').forEach(x=>x.classList.remove('on'));b.classList.add('on');iaspect=b.dataset.a;});
document.getElementById('ifile').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{isrc=r.result;document.getElementById('ithumb').src=isrc;document.getElementById('ibox').classList.remove('hidden');document.getElementById('iuplabel').classList.add('hidden');};r.readAsDataURL(f);};
function clearSrc(){isrc=null;document.getElementById('ifile').value='';document.getElementById('ibox').classList.add('hidden');document.getElementById('iuplabel').classList.remove('hidden');}
document.getElementById('istrength').oninput=e=>document.getElementById('istrval').textContent=(e.target.value/100).toFixed(2);
document.getElementById('iprompt').addEventListener('keydown',e=>{if((e.metaKey||e.ctrlKey)&&e.key==='Enter')goImage();});
// video tab
let vmode='text',vsrc=null;
let vengine='ltx';
document.querySelectorAll('#vengine button').forEach(b=>b.onclick=()=>{document.querySelectorAll('#vengine button').forEach(x=>x.classList.remove('on'));b.classList.add('on');vengine=b.dataset.e;
 const ltx=vengine==='ltx';
 document.getElementById('vsecwrap').style.display=ltx?'':'none';
 document.getElementById('vframelbl').style.display=ltx?'none':'';
 document.getElementById('vframes').style.display=ltx?'none':'';
 document.getElementById('vframeval').style.display=ltx?'none':'';
});
document.querySelectorAll('#vmode button').forEach(b=>b.onclick=()=>{document.querySelectorAll('#vmode button').forEach(x=>x.classList.remove('on'));b.classList.add('on');vmode=b.dataset.m;document.getElementById('vtext').classList.toggle('hidden',vmode!=='text');document.getElementById('vimage').classList.toggle('hidden',vmode!=='image');});
document.getElementById('vfile').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{vsrc=r.result;const t=document.getElementById('vthumb');t.src=vsrc;t.classList.remove('hidden');};r.readAsDataURL(f);};
document.getElementById('vframes').oninput=e=>document.getElementById('vframeval').textContent=e.target.value;
// 3d tab
let dsrc=null;
document.getElementById('dfile').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{dsrc=r.result;const t=document.getElementById('dthumb');t.src=dsrc;t.classList.remove('hidden');};r.readAsDataURL(f);};
// inpaint tab - canvas mask painter
let inImg=null, inMaskCanvas=null, inPainting=false;
const inCanvas=document.getElementById('incanvas'), inCtx=inCanvas.getContext('2d');
function loadInpaintImage(durl){const im=new Image();im.onload=()=>{
  inImg=im; inCanvas.width=im.naturalWidth; inCanvas.height=im.naturalHeight;
  // cap display width
  const maxw=640; if(im.naturalWidth>maxw){inCanvas.style.width=maxw+'px';}
  inMaskCanvas=document.createElement('canvas'); inMaskCanvas.width=im.naturalWidth; inMaskCanvas.height=im.naturalHeight;
  const mc=inMaskCanvas.getContext('2d'); mc.fillStyle='black'; mc.fillRect(0,0,inMaskCanvas.width,inMaskCanvas.height);
  redrawInpaint();
  document.getElementById('incanvaswrap').style.display='block';
  ['inbrushlbl','inbrush'].forEach(id=>document.getElementById(id).classList.remove('hidden'));
  document.getElementById('inclear').style.display='inline-block';
  document.getElementById('insmart').style.display='inline-block';
  document.getElementById('inuplabel').classList.add('hidden');
};im.src=durl;}
document.getElementById('infile').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>loadInpaintImage(r.result);r.readAsDataURL(f);};
function redrawInpaint(){ if(!inImg)return; inCtx.drawImage(inImg,0,0);
  // overlay mask in semi-transparent red
  const mc=inMaskCanvas.getContext('2d'); const d=mc.getImageData(0,0,inMaskCanvas.width,inMaskCanvas.height);
  inCtx.save(); inCtx.globalAlpha=0.5; inCtx.fillStyle='red';
  // draw red where mask is white - use a temp canvas
  const tmp=document.createElement('canvas'); tmp.width=inMaskCanvas.width; tmp.height=inMaskCanvas.height; const tc=tmp.getContext('2d');
  const td=tc.createImageData(tmp.width,tmp.height);
  for(let i=0;i<d.data.length;i+=4){ if(d.data[i]>128){ td.data[i]=255; td.data[i+3]=140; } }
  tc.putImageData(td,0,0); inCtx.globalAlpha=1; inCtx.drawImage(tmp,0,0); inCtx.restore();
}
function inPos(e){ const r=inCanvas.getBoundingClientRect(); const sx=inCanvas.width/r.width, sy=inCanvas.height/r.height;
  const cx=(e.touches?e.touches[0].clientX:e.clientX)-r.left, cy=(e.touches?e.touches[0].clientY:e.clientY)-r.top;
  return [cx*sx, cy*sy]; }
function paint(e){ if(!inPainting||!inMaskCanvas)return; e.preventDefault(); const [x,y]=inPos(e);
  const mc=inMaskCanvas.getContext('2d'); mc.fillStyle='white'; mc.beginPath();
  mc.arc(x,y,+document.getElementById('inbrush').value,0,7); mc.fill(); redrawInpaint(); }
inCanvas.addEventListener('mousedown',e=>{ if(inSmart){e.preventDefault();smartClick(e);return;} inPainting=true;paint(e);});
inCanvas.addEventListener('mousemove',paint);
window.addEventListener('mouseup',()=>inPainting=false);
inCanvas.addEventListener('touchstart',e=>{inPainting=true;paint(e);});
inCanvas.addEventListener('touchmove',paint);
window.addEventListener('touchend',()=>inPainting=false);
function clearMask(){ if(!inMaskCanvas)return; inPoints=[]; const mc=inMaskCanvas.getContext('2d'); mc.fillStyle='black'; mc.fillRect(0,0,inMaskCanvas.width,inMaskCanvas.height); redrawInpaint(); }
let inSmart=false, inPoints=[];
function toggleSmart(){ inSmart=!inSmart; const b=document.getElementById('insmart');
 b.style.background=inSmart?'var(--acc)':''; b.style.color=inSmart?'#04121f':'';
 inCanvas.style.cursor=inSmart?'pointer':'crosshair'; if(!inSmart)inPoints=[]; }
async function smartClick(e){
 if(!inImg)return; const [x,y]=inPos(e); inPoints.push([Math.round(x),Math.round(y)]);
 const tmp=document.createElement('canvas'); tmp.width=inImg.naturalWidth; tmp.height=inImg.naturalHeight;
 tmp.getContext('2d').drawImage(inImg,0,0); const b64=tmp.toDataURL('image/png');
 const b=document.getElementById('insmart'); const o=b.textContent; b.textContent='selecting...'; b.disabled=true;
 try{const d=await post('/api/segment',{image:b64,points:inPoints});
  if(d.error){alert('Smart select: '+d.error);}
  else{const m=new Image(); m.onload=()=>{ const mc=inMaskCanvas.getContext('2d');
    mc.save(); mc.globalCompositeOperation='lighten'; mc.drawImage(m,0,0,inMaskCanvas.width,inMaskCanvas.height); mc.restore(); redrawInpaint(); };
   m.src='/img/'+d.filename+'?t='+Date.now(); }
 }catch(err){alert(err);}
 b.textContent=o; b.disabled=false;
}
async function goInpaint(){
 if(!inImg){alert('Upload an image');return;}
 if(!inMaskCanvas){return;}
 const btn=document.getElementById('ingo');btn.disabled=true;busy('Inpainting...');
 // export original image + mask
 const ic=document.createElement('canvas');ic.width=inImg.naturalWidth;ic.height=inImg.naturalHeight;ic.getContext('2d').drawImage(inImg,0,0);
 try{const d=await post('/api/inpaint',{image:ic.toDataURL('image/png'),mask:inMaskCanvas.toDataURL('image/png'),prompt:document.getElementById('inprompt').value.trim()});
  if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;
}
// control tab
let ctype='depth',csrc=null;
document.querySelectorAll('#ctype button').forEach(b=>b.onclick=()=>{document.querySelectorAll('#ctype button').forEach(x=>x.classList.remove('on'));b.classList.add('on');ctype=b.dataset.c;
 document.getElementById('cdir').classList.toggle('hidden',ctype!=='relight');
 const noStr=(ctype==='edit'||ctype==='relight');
 document.getElementById('cstrength').style.display=noStr?'none':'';
 document.getElementById('cstrval').style.display=noStr?'none':'';
 document.getElementById('cprompt').placeholder = ctype==='edit' ? 'Describe the CHANGE... e.g. make it night time / remove the chair / turn the car red'
   : ctype==='relight' ? 'Describe the new lighting/scene... e.g. warm sunset light on a beach'
   : 'Describe what to generate... it\'ll follow the structure of your reference image';
});
document.getElementById('cfile').onchange=e=>{const f=e.target.files[0];if(!f)return;const r=new FileReader();r.onload=()=>{csrc=r.result;const t=document.getElementById('cthumb');t.src=csrc;t.classList.remove('hidden');};r.readAsDataURL(f);};
document.getElementById('cstrength').oninput=e=>document.getElementById('cstrval').textContent=(e.target.value/100).toFixed(2);
async function goControl(){
 const p=document.getElementById('cprompt').value.trim();if(!p){alert('Enter a prompt');return;}
 if(!csrc){alert('Upload a reference image');return;}
 const btn=document.getElementById('cgo');btn.disabled=true;busy('Generating with '+ctype+' control...');
 try{const s=document.getElementById('cstrength').value/100;
  const d = ctype==='style' ? await post('/api/ipadapter',{prompt:p,image:csrc,strength:s})
    : ctype==='edit' ? await post('/api/kontext',{prompt:p,image:csrc})
    : ctype==='relight' ? await post('/api/relight',{prompt:p,image:csrc,direction:document.getElementById('cdir').value})
    : await post('/api/controlnet',{prompt:p,image:csrc,control_type:ctype,strength:s});
  if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;
}
// audio tab
document.querySelectorAll('#amode button').forEach(b=>b.onclick=()=>{document.querySelectorAll('#amode button').forEach(x=>x.classList.remove('on'));b.classList.add('on');const m=b.dataset.am;document.getElementById('au-music').classList.toggle('hidden',m!=='music');document.getElementById('au-speech').classList.toggle('hidden',m!=='speech');});
async function goMusic(){
 const tags=document.getElementById('atags').value.trim();if(!tags){alert('Enter some style tags');return;}
 const lyrics=document.getElementById('alyrics').value.trim();const secs=+document.getElementById('asecs').value;
 const btn=document.getElementById('amgo');btn.disabled=true;busy('Composing '+secs+'s of music...');
 try{const d=await post('/api/music',{tags,lyrics,seconds:secs});if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;
}
async function goTTS(){
 const text=document.getElementById('attext').value.trim();if(!text){alert('Type something to speak');return;}
 const voice=document.getElementById('avoice').value;
 const btn=document.getElementById('atgo');btn.disabled=true;busy('Synthesizing speech...');
 try{const d=await post('/api/tts',{text,voice});if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;
}
// transcribe tab
let tsrc=null;
document.getElementById('tfile').onchange=e=>{const f=e.target.files[0];if(!f)return;const n=document.getElementById('tname');n.textContent='🎧 '+f.name;n.classList.remove('hidden');const r=new FileReader();r.onload=()=>tsrc=r.result;r.readAsDataURL(f);};
async function goTranscribe(){
 if(!tsrc){alert('Upload audio or video first');return;}
 const btn=document.getElementById('tgo');btn.disabled=true;busy('Transcribing on your GPU...');
 try{const d=await post('/api/transcribe',{media:tsrc});
  if(d.error)showErr(d.error);
  else{document.getElementById('stage').innerHTML='<div class=result style=padding:16px><div class=meta style=margin-bottom:10px><a href="/img/'+d.srt+'" download>&#11015; download SRT subtitles</a></div><div style="white-space:pre-wrap;line-height:1.6">'+d.text.replace(/</g,'&lt;')+'</div></div>';}
 }catch(e){showErr(e);}
 btn.disabled=false;
}
// remove-bg tab (image or video)
let rsrc=null,rIsVideo=false;
document.getElementById('rfile').onchange=e=>{const f=e.target.files[0];if(!f)return;rIsVideo=f.type.startsWith('video');const r=new FileReader();r.onload=()=>{rsrc=r.result;
  const t=document.getElementById('rthumb'),v=document.getElementById('rvid');
  if(rIsVideo){t.classList.add('hidden');v.classList.remove('hidden');}else{t.src=rsrc;t.classList.remove('hidden');v.classList.add('hidden');}};r.readAsDataURL(f);};
async function goRemoveBg(){
 if(!rsrc){alert('Upload a photo or video first');return;}
 const btn=document.getElementById('rgo');btn.disabled=true;
 busy(rIsVideo?'Removing background from video (~1 min)...':'Removing background...');
 try{
  let d;
  if(rIsVideo){d=await post('/api/removebg_video',{video:rsrc});}
  else{d=await post('/api/removebg',{image:rsrc});}
  if(d.error)showErr(d.error);
  else{showMedia(d.filename,d.seconds); if(d.extra){document.getElementById('stage').insertAdjacentHTML('beforeend','<div class=meta style=margin-top:8px><a href="/img/'+d.extra+'" download>&#11015; also: green-screen MP4</a></div>');}}
 }catch(e){showErr(e);}
 btn.disabled=false;
}

function hidePH(){const p=document.getElementById('stageph');if(p)p.classList.add('hidden');}
function toStage(){ // on narrow screens the preview is below - bring it into view
  if(window.innerWidth<1080){const s=document.getElementById('stage');if(s)s.scrollIntoView({behavior:'smooth',block:'nearest'});}
}
let busyTimer=null,busyT0=0;
function stopBusyTimer(){if(busyTimer){clearInterval(busyTimer);busyTimer=null;}}
function busy(msg){
 hidePH();busyT0=Date.now();
 document.getElementById('stage').innerHTML=
  '<div class=result><div class=loading><div class=spin></div><span id=busymsg>'+msg+'</span><span id=busyel></span></div>'+
  '<div style="text-align:center;padding:0 0 16px"><button class=pbtn id=cancelbtn onclick=cancelJob()>Cancel</button></div></div>';
 stopBusyTimer();
 busyTimer=setInterval(function(){var e=document.getElementById('busyel');
   if(!e){stopBusyTimer();return;} e.textContent=' \u00b7 '+Math.round((Date.now()-busyT0)/1000)+'s';},1000);
 toStage();
}
async function cancelJob(){
 var m=document.getElementById('busymsg'),b=document.getElementById('cancelbtn');
 if(m)m.textContent='Cancelling\u2026'; if(b){b.disabled=true;b.textContent='cancelling';}
 try{await post('/api/cancel',{});}catch(e){}
}
function showErr(e){hidePH();stopBusyTimer();document.getElementById('stage').innerHTML='<div class=err>Error: '+e+'</div>';toStage();}
async function post(url,body){return await (await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();}

async function goImage(){
 const p=document.getElementById('iprompt').value.trim();if(!p)return;
 const btn=document.getElementById('igo');btn.disabled=true;busy(isrc?'Transforming your image...':'Generating on your RTX 2070...');
 const body={prompt:p,model:document.getElementById('imodel').value,aspect:iaspect};
 const ng=document.getElementById('inegative').value.trim(); if(ng)body.negative=ng;
 const lr=document.getElementById('ilora').value; if(lr){body.lora=lr;body.lora_strength=document.getElementById('ilorastr').value/100;}
 if(isrc){body.image=isrc;body.strength=document.getElementById('istrength').value/100;}
 try{const d=await post('/api/image',body);if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;
}
async function goVideo(){
 const btn=document.getElementById('vgo');btn.disabled=true;
 try{let d;
  if(vengine==='ltx'){
   const secs=+document.getElementById('vsecs').value;
   if(vmode==='text'){const p=document.getElementById('vprompt').value.trim();if(!p){btn.disabled=false;return;}busy('LTX generating '+secs+'s of video...');d=await post('/api/ltx_video',{prompt:p,seconds:secs});}
   else{if(!vsrc){alert('Upload an image first');btn.disabled=false;return;}const vip=document.getElementById('viprompt').value.trim();busy('LTX animating your image ('+secs+'s)...');d=await post('/api/ltx_video',{prompt:vip||'natural subtle motion, cinematic',image:vsrc,seconds:secs});}
  } else {
   if(vmode==='text'){const p=document.getElementById('vprompt').value.trim();if(!p){btn.disabled=false;return;}busy('Animating (~1 min)...');d=await post('/api/text_video',{prompt:p,frames:+document.getElementById('vframes').value});}
   else{if(!vsrc){alert('Upload an image first');btn.disabled=false;return;}const vip=document.getElementById('viprompt').value.trim();busy(vip?'Animating with your prompt (~1 min)...':'Bringing your image to life (~2 min)...');d=await post('/api/image_video',{image:vsrc,frames:+document.getElementById('vframes').value,prompt:vip});}
  }
  if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);
 }catch(e){showErr(e);}
 btn.disabled=false;
}
async function go3D(){
 if(!dsrc){alert('Upload a photo first');return;}
 const m=document.getElementById('dmethod').value;
 const btn=document.getElementById('dgo');btn.disabled=true;busy(m==='hunyuan3d'?'Building solid 3D model (~65s)...':'Building 3D mesh (~20s)...');
 try{const d=await post('/api/threed',{image:dsrc,method:m});if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;
}
function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
async function showMedia(f,secs){
 hidePH();stopBusyTimer();
 const stage=document.getElementById('stage');const ext=f.split('.').pop().toLowerCase();const src='/img/'+f+'?t='+Date.now();
 let media;
 if(ext==='mp4')media=`<video src="${src}" controls autoplay loop muted></video>`;
 else if(ext==='glb')media=`<model-viewer src="${src}" camera-controls auto-rotate touch-action=pan-y></model-viewer>`;
 else if(ext==='mp3'||ext==='wav')media=`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;height:220px"><svg class=ic style="width:46px;height:46px;color:var(--acc)"><use href=#i-music></use></svg><audio src="${src}" controls autoplay style="width:90%"></audio></div>`;
 else media=`<img src="${src}">`;
 stage.innerHTML=`<div class=result>${media}<div class=meta>
   <span style="display:inline-flex;align-items:center;gap:5px"><svg class=ic style="width:13px;height:13px"><use href=#i-clock></use></svg>${secs}s</span>
   <button class=pbtn onclick="togglePrompt('${f}')">prompt</button>
   <a href="${src}" download="${f}" style="display:inline-flex;align-items:center;gap:5px;margin-left:auto"><svg class=ic style="width:13px;height:13px"><use href=#i-download></use></svg>download</a>
   </div><div class="pbox hidden" id=pbox></div></div>`;
 loadGrid(); toStage();
}
let METAS={};
async function loadMeta(){try{METAS=await (await fetch('/api/meta')).json();}catch(e){}}
async function togglePrompt(f){
 const box=document.getElementById('pbox'); if(!box)return;
 if(!box.classList.contains('hidden')){box.classList.add('hidden');return;}
 await loadMeta(); const m=METAS[f];
 if(!m){box.innerHTML='<div class=lbl>no prompt recorded</div><div class=val style="color:var(--mut)">This file was made before prompt history was added.</div>';}
 else{
  const main=m.prompt||m.tags||m.text||'';
  let h='';
  if(main){h+='<div class=lbl>Prompt</div><div class=val id=pv>'+esc(main)+'</div>';}
  if(m.negative){h+='<div class=lbl>Negative</div><div class=val>'+esc(m.negative)+'</div>';}
  const bits=[m.model,m.kind,m.aspect,m.control_type,m.method,m.voice].filter(Boolean).join(' &middot; ');
  if(bits)h+='<div class=lbl>Settings</div><div class=val>'+bits+'</div>';
  h+='<div class=prow>';
  if(main){h+=`<button class=pbtn onclick="copyPrompt(this,${JSON.stringify(main).replace(/"/g,'&quot;')})"><svg class=ic style="width:12px;height:12px;vertical-align:-2px"><use href=#i-copy></use></svg> copy</button>`;
           h+=`<button class=pbtn onclick="reusePrompt(${JSON.stringify(main).replace(/"/g,'&quot;')},${JSON.stringify(m.negative||'').replace(/"/g,'&quot;')})"><svg class=ic style="width:12px;height:12px;vertical-align:-2px"><use href=#i-reuse></use></svg> reuse</button>`;}
  h+='</div>';
  box.innerHTML=h;
 }
 box.classList.remove('hidden');
}
function copyPrompt(btn,txt){navigator.clipboard.writeText(txt).then(()=>{const o=btn.innerHTML;btn.textContent='copied';setTimeout(()=>btn.innerHTML=o,1200);});}
function reusePrompt(p,n){
 tab('image');
 const ip=document.getElementById('iprompt'); if(ip)ip.value=p;
 const inb=document.getElementById('inegative'); if(inb)inb.value=n||'';
 if(ip){ip.focus();ip.scrollIntoView({behavior:'smooth',block:'center'});}
}
let GRID=[];
const I=n=>`<svg class=ic><use href=#i-${n}></use></svg>`;
let pickTarget=null;
async function openPicker(target,kinds){
 pickTarget=target;
 let files=[]; try{files=await (await fetch('/api/gallery')).json();}catch(e){}
 const ok=f=>{const e=f.split('.').pop().toLowerCase();
  return (kinds.includes('img')&&['png','jpg','jpeg'].includes(e))||(kinds.includes('mp4')&&e==='mp4');};
 const list=files.filter(ok);
 document.getElementById('pickgrid').innerHTML=list.length?list.map(f=>{
  const e=f.split('.').pop().toLowerCase();
  const media=e==='mp4'?`<video src="/img/${f}" muted onmouseover="this.play()" onmouseout="this.pause()"></video>`:`<img src="/img/${f}" loading=lazy>`;
  return `<div class=cell onclick="pickFile('${f}')">${media}</div>`;}).join('')
  :'<div style="color:var(--mut);padding:24px">Nothing suitable in the gallery yet</div>';
 document.getElementById('picker').classList.remove('hidden');
}
function closePicker(){document.getElementById('picker').classList.add('hidden');}
async function pickFile(f){
 closePicker();
 const blob=await (await fetch('/img/'+f)).blob();
 const d=await new Promise(res=>{const r=new FileReader();r.onload=()=>res(r.result);r.readAsDataURL(blob);});
 applyPick(pickTarget,d,f);
}
function applyPick(t,d,f){
 const isVid=f.split('.').pop().toLowerCase()==='mp4';
 if(t==='image'){isrc=d;document.getElementById('ithumb').src=d;document.getElementById('ibox').classList.remove('hidden');document.getElementById('iuplabel').classList.add('hidden');}
 else if(t==='video'){vsrc=d;const th=document.getElementById('vthumb');th.src=d;th.classList.remove('hidden');}
 else if(t==='control'){csrc=d;const th=document.getElementById('cthumb');th.src=d;th.classList.remove('hidden');}
 else if(t==='threed'){dsrc=d;const th=document.getElementById('dthumb');th.src=d;th.classList.remove('hidden');}
 else if(t==='inpaint'){loadInpaintImage(d);}
 else if(t==='removebg'){rIsVideo=isVid;rsrc=d;const th=document.getElementById('rthumb'),v=document.getElementById('rvid');
  if(isVid){th.classList.add('hidden');v.classList.remove('hidden');}else{th.src=d;th.classList.remove('hidden');v.classList.add('hidden');}}
}
async function loadGrid(){
 GRID=await (await fetch('/api/gallery')).json();
 loadMeta();
 document.getElementById('grid').innerHTML=GRID.map(f=>{
  const ext=f.split('.').pop().toLowerCase();
  let media,badge='';
  const smooth=(ext==='mp4')?`<button class=xbtn title="Smooth 2x (RIFE)" onclick="smoothVid('${f}',this)">&#8779;</button>`:'';
  if(ext==='mp4'){media=`<video src="/img/${f}" muted loop onmouseover="this.play()" onmouseout="this.pause()"></video>`;badge='<div class=badge>MP4</div>';}
  else if(ext==='glb'){media=`<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--acc)"><svg class=ic style="width:32px;height:32px"><use href=#i-box></use></svg></div>`;badge='<div class=badge>3D</div>';}
  else if(ext==='mp3'||ext==='wav'){media=`<div onclick="new Audio('/img/${f}').play()" style="display:flex;align-items:center;justify-content:center;height:100%;cursor:pointer;color:var(--acc)"><svg class=ic style="width:32px;height:32px"><use href=#i-music></use></svg></div>`;badge='<div class=badge>AUDIO</div>';}
  else media=`<img src="/img/${f}" loading=lazy>`;
  const isimg=(ext==='png'||ext==='jpg');
  const up=isimg?`<button class=xbtn title="Upscale 2x (best before a 2nd face-fix)" onclick="up('${f}',this,2)">2&times;</button>`+
                 `<button class=xbtn title="Upscale 4x" onclick="up('${f}',this,4)">4&times;</button>`:'';
  const desc=isimg?`<button class=xbtn title="Describe -> prompt (Florence-2)" onclick="describeImg('${f}',this)">Aa</button>`:'';
  const ff=isimg?`<button title="Fix faces (SDXL regenerate) - use for close-up portraits; do it at 1024-2048, not 4K" onclick="fixfaces('${f}',this)">${I('smile')}</button>`+
                 `<button title="Enhance face (CodeFormer) - best for SMALL/soft faces in wider shots. Not for large close-up portraits." onclick="enhface('${f}',this)">${I('wand')}</button>`:'';
  return `<div class=cell>${badge}${media}<div class=ov>`+
    `<button title="Show prompt" onclick="showMeta('${f}')">${I('text')}</button>`+
    `<button title=Open onclick="window.open('/img/${f}','_blank')">${I('expand')}</button>`+
    `${ff}${up}${desc}${smooth}<button title=Download onclick="dl('${f}')">${I('download')}</button>`+
    `<button class=del title=Delete onclick="del('${f}')">${I('trash')}</button></div></div>`;
 }).join('');
}
// clicking a gallery item's prompt loads it into the preview panel with its details
async function showMeta(f){
 const ext=f.split('.').pop().toLowerCase();
 await loadMeta();
 showMedia(f, (METAS[f]&&METAS[f].secs)||0);
 setTimeout(()=>togglePrompt(f),60);
}
function dl(f){const a=document.createElement('a');a.href='/img/'+f;a.download=f;a.click();}
async function del(f){if(!confirm('Delete '+f+'?'))return;await post('/api/delete',{filename:f});loadGrid();}
async function up(f,btn,factor){btn.innerHTML='&hellip;';btn.disabled=true;const d=await post('/api/upscale',{filename:f,factor:factor||4});if(d.error){alert(d.error);}loadGrid();}
async function describeSrc(){
 if(!isrc){alert('Upload a source image first');return;}
 const b=document.getElementById('idesc'); const o=b.textContent; b.textContent='describing...'; b.disabled=true;
 try{const d=await post('/api/describe',{image:isrc});
  if(d.error){alert('Describe: '+d.error);}
  else{const ip=document.getElementById('iprompt'); ip.value=d.text; ip.focus();}
 }catch(e){alert(e);}
 b.textContent=o; b.disabled=false;
}
async function describeImg(f,btn){btn.textContent='...';btn.disabled=true;
 try{const d=await post('/api/describe',{filename:f});
  if(d.error){alert('Describe: '+d.error);}
  else{tab('image');const ip=document.getElementById('iprompt');ip.value=d.text;ip.focus();ip.scrollIntoView({behavior:'smooth',block:'center'});}
 }catch(e){alert(e);} btn.textContent='Aa';btn.disabled=false;loadGrid();}
async function smoothVid(f,btn){btn.textContent='...';btn.disabled=true;busy('RIFE smoothing 2x...');
 try{const d=await post('/api/interpolate',{filename:f,multiplier:2});
  if(d.error)showErr(d.error);else showMedia(d.filename,d.seconds);}catch(e){showErr(e);}
 btn.disabled=false;loadGrid();}
async function enhface(f,btn){btn.innerHTML='&hellip;';btn.disabled=true;const d=await post('/api/enhanceface',{filename:f,fidelity:0.5});if(d.error){alert('Enhance: '+d.error);}else{showMedia(d.filename,d.seconds);}loadGrid();}
async function fixfaces(f,btn){btn.innerHTML='&hellip;';btn.disabled=true;const d=await post('/api/facefix',{filename:f});if(d.error){alert('Fix faces: '+d.error);}loadGrid();}
async function clearAll(){if(!confirm('Delete ALL '+GRID.length+' items?'))return;for(const f of GRID)await post('/api/delete',{filename:f});loadGrid();}
async function loadModels(){
 const m=await (await fetch('/api/models')).json();
 const sel=document.getElementById('imodel');
 sel.innerHTML=m.map(x=>`<option value="${x.key}">${x.label}</option>`).join('');
 const syncNeg=()=>{const isFlux=sel.value==='flux';
   document.getElementById('inegnote').classList.toggle('hidden',!isFlux);
   document.getElementById('inegative').disabled=isFlux;
   document.getElementById('inegative').style.opacity=isFlux?0.45:1;};
 sel.onchange=syncNeg; syncNeg();
 try{const lo=await (await fetch('/api/loras')).json();
  if(lo.length){const ls=document.getElementById('ilora');
   ls.innerHTML='<option value="">No LoRA</option>'+lo.map(x=>`<option value="${x}">${x.replace(/\.(safetensors|pt)$/,'')}</option>`).join('');
   ls.classList.remove('hidden');
   ls.onchange=()=>document.getElementById('ilorastrwrap').classList.toggle('hidden',!ls.value);
  }}catch(e){}
}
document.getElementById('ilorastr').oninput=e=>document.getElementById('ilorastrval').textContent=(e.target.value/100).toFixed(2);
loadModels();loadGrid();
</script></div></body></html>
```

## Appendix C — supporting files

### `~/pc-ai/config/studio_models.json` (on your main computer)
Defines the image models in the dropdown. Add models by adding entries. `prefix`/`neg` = per-model prompt shaping (Pony needs it); `engine`: `flux` or `sd3` for those special pipelines; `pending: true` hides a model while it's still downloading.
```json
{
  "juggernaut": {
    "label": "Juggernaut XL (quality)",
    "ckpt": "Juggernaut_XL_Lightning.safetensors",
    "steps": 6,
    "cfg": 2.0,
    "sampler": "dpmpp_sde",
    "scheduler": "karras",
    "sizes": {
      "square": [
        1024,
        1024
      ],
      "portrait": [
        832,
        1216
      ],
      "landscape": [
        1216,
        832
      ]
    }
  },
  "dreamshaper": {
    "label": "DreamShaper (fast, SD1.5)",
    "ckpt": "DreamShaper_8_pruned.safetensors",
    "steps": 25,
    "cfg": 7.0,
    "sampler": "dpmpp_2m",
    "scheduler": "karras",
    "sizes": {
      "square": [
        512,
        512
      ],
      "portrait": [
        512,
        768
      ],
      "landscape": [
        768,
        512
      ]
    }
  },
  "cyberpony": {
    "label": "CyberRealistic Pony (photoreal, best posing)",
    "ckpt": "CyberRealisticPony_V18.safetensors",
    "steps": 28,
    "cfg": 6.0,
    "sampler": "dpmpp_2m",
    "scheduler": "karras",
    "prefix": "score_9, score_8_up, score_7_up, source_photo, realistic, photorealistic, ",
    "neg": "score_6, score_5, score_4, cartoon, anime, illustration, 3d render, painting, drawing, blurry, low quality, worst quality, watermark, text, signature, deformed, extra limbs, bad hands",
    "sizes": {
      "square": [
        1024,
        1024
      ],
      "portrait": [
        832,
        1216
      ],
      "landscape": [
        1216,
        832
      ]
    }
  },
  "sd35": {
    "label": "SD 3.5 Medium (best text in images)",
    "engine": "sd3",
    "shift": 3.0,
    "ckpt": "sd3.5_medium.safetensors",
    "steps": 30,
    "cfg": 4.5,
    "sampler": "dpmpp_2m",
    "scheduler": "sgm_uniform",
    "neg": "blurry, low quality, distorted, watermark, ugly, deformed",
    "sizes": {
      "square": [
        1024,
        1024
      ],
      "portrait": [
        896,
        1152
      ],
      "landscape": [
        1152,
        896
      ]
    }
  },
  "flux": {
    "label": "Flux.1 schnell (best prompt-following)",
    "engine": "flux",
    "ckpt": "flux1-schnell-Q4_K_S.gguf",
    "steps": 4,
    "cfg": 1.0,
    "sampler": "euler",
    "scheduler": "simple",
    "sizes": {
      "square": [
        1024,
        1024
      ],
      "portrait": [
        896,
        1152
      ],
      "landscape": [
        1152,
        896
      ]
    }
  },
  "realvisxl": {
    "label": "RealVis XL (photoreal)",
    "ckpt": "realvisxl.safetensors",
    "steps": 6,
    "cfg": 2.0,
    "sampler": "dpmpp_sde",
    "scheduler": "karras",
    "sizes": {
      "square": [
        1024,
        1024
      ],
      "portrait": [
        832,
        1216
      ],
      "landscape": [
        1216,
        832
      ]
    }
  }
}```

### `C: sers\YOUR_PC_USER	ts.ps1` (on the PC — Piper speech)
```powershell
param($b64,$voice,$out)
$text=[System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))
Set-Location C:\Users\YOUR_PC_USER\piper
$text | .\piper.exe -m voices\$voice -f $out 2>$null
```

### `C: sers\YOUR_PC_USER\kokoro_tts.py` (on the PC — Kokoro speech)
```python
# Kokoro TTS: kokoro_tts.py <b64text> <voice> <out.wav> [speed]
# Runs in its OWN venv (C:\Users\YOUR_PC_USER\kokoro-env) so it can't break ComfyUI's torch.
import sys, base64, soundfile as sf
from kokoro_onnx import Kokoro
text = base64.b64decode(sys.argv[1]).decode("utf-8")
voice = sys.argv[2]; out = sys.argv[3]
speed = float(sys.argv[4]) if len(sys.argv) > 4 else 1.0
k = Kokoro(r"C:\Users\YOUR_PC_USER\kokoro\kokoro-v1.0.onnx", r"C:\Users\YOUR_PC_USER\kokoro\voices-v1.0.bin")
samples, sr = k.create(text, voice=voice, speed=speed, lang="en-us")
sf.write(out, samples, sr)
print("OK", out)
```

### `C: sers\YOUR_PC_USER\whisper_transcribe.py` (on the PC — transcription)
```python
# Whisper transcription: whisper_transcribe.py <src_media> <out.srt> <out.txt>
# Runs in ComfyUI's venv (needs: pip install faster-whisper). First run downloads the model.
import sys
from faster_whisper import WhisperModel

src, srt_out, txt_out = sys.argv[1], sys.argv[2], sys.argv[3]
model = WhisperModel("large-v3-turbo", device="cuda", compute_type="float16",
                     download_root="E:/whisper-models")

def ts(t):
    h = int(t // 3600); m = int((t % 3600) // 60); s = t % 60
    return f"{h:02d}:{m:02d}:{s:06.3f}".replace('.', ',')

segments, info = model.transcribe(src, beam_size=5)
srt_lines, text_parts = [], []
for i, seg in enumerate(segments, 1):
    srt_lines.append(f"{i}\n{ts(seg.start)} --> {ts(seg.end)}\n{seg.text.strip()}\n")
    text_parts.append(seg.text.strip())

open(srt_out, "w", encoding="utf-8").write("\n".join(srt_lines))
open(txt_out, "w", encoding="utf-8").write(" ".join(text_parts))
print("OK", srt_out)
```

---

*Guide assembled from a real build. Everything here was done and verified on an 8 GB RTX 2070 Super. Adapt freely — and if a step fights you, the answer is almost always in Part 10. Good luck. 🛠️*
