This commit is contained in:
mike
2026-06-20 04:44:35 +02:00
parent f49af6b33a
commit cfb2e45f1f
21 changed files with 4579 additions and 273 deletions

View File

@@ -1,14 +0,0 @@
{
"id": "devstral24b-junie:latest",
"baseUrl": "http://localhost:11434/v1/chat/completions",
"apiType": "OpenAICompletion",
"temperature": 0.05,
"primaryModel": {
"id": "devstral24b-junie:latest",
"temperature": 0.05
},
"fasterModel": {
"id": "devstral24b-junie:latest",
"temperature": 0.05
}
}

View File

@@ -1,13 +0,0 @@
{
"baseUrl": "http://localhost:11434/v1/responses",
"id": "qwen2.5-coder:32b",
"apiType": "OpenAIResponses",
"temperature": 0.1,
"primaryModel": {
"id": "qwen2.5-coder:32b",
"temperature": 0.1
},
"fasterModel": {
"id": "qwen2.5-coder:1.5b"
}
}

View File

@@ -1,13 +0,0 @@
{
"baseUrl": "http://localhost:11440/v1/responses",
"id": "qwen3-coder:30b",
"apiType": "OpenAIResponses",
"temperature": 0.3,
"primaryModel": {
"id": "qwen3-coder:30b",
"temperature": 0.3
},
"fasterModel": {
"id": "qwen2.5-coder:1.5b"
}
}

151
.trash/architecture.svg Normal file
View File

@@ -0,0 +1,151 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800">
<style>
.component {
fill: #e3f2fd;
stroke: #1976d2;
stroke-width: 2;
rx: 10;
ry: 10;
}
.label {
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: bold;
text-anchor: middle;
}
.sublabel {
font-family: Arial, sans-serif;
font-size: 12px;
text-anchor: middle;
}
.connection {
stroke: #757575;
stroke-width: 2;
marker-end: url(#arrowhead);
}
.database {
fill: #ffebee;
stroke: #d32f2f;
stroke-width: 2;
rx: 10;
ry: 10;
}
.service {
fill: #e8f5e9;
stroke: #388e3c;
stroke-width: 2;
rx: 10;
ry: 10;
}
.system {
fill: #fff3e0;
stroke: #ef6c00;
stroke-width: 2;
rx: 10;
ry: 10;
}
</style>
<!-- Main system container -->
<rect x="50" y="50" width="1100" height="700" class="system" fill="#fff3e0"/>
<text x="600" y="40" class="label" text-anchor="middle">Qwen Image Edit Rapid-AIO v23 System</text>
<!-- Database Component -->
<rect x="850" y="150" width="200" height="100" class="database"/>
<text x="950" y="170" class="label">PostgreSQL Database</text>
<text x="950" y="190" class="sublabel">host: 192.168.1.160</text>
<text x="950" y="210" class="sublabel">port: 5433</text>
<text x="950" y="230" class="sublabel">dbname: dv</text>
<!-- ComfyUI Backend Component -->
<rect x="850" y="300" width="200" height="100" class="component"/>
<text x="950" y="320" class="label">ComfyUI Backend</text>
<text x="950" y="340" class="sublabel">Qwen-Rapid-NSFW-v23</text>
<text x="950" y="360" class="sublabel">GGUF Model (21.8GB)</text>
<!-- API Service Component -->
<rect x="850" y="450" width="200" height="100" class="service"/>
<text x="950" y="470" class="label">FastAPI Service</text>
<text x="950" y="490" class="sublabel">edit_api.py</text>
<text x="950" y="510" class="sublabel">Image Processing</text>
<!-- Watcher Service Component -->
<rect x="850" y="600" width="200" height="100" class="service"/>
<text x="950" y="620" class="label">Folder Watcher</text>
<text x="950" y="640" class="sublabel">watcher.py</text>
<text x="950" y="660" class="sublabel">car.html Generator</text>
<!-- Input/Output Directories -->
<rect x="50" y="150" width="200" height="100" class="component"/>
<text x="150" y="170" class="label">Stage Directory</text>
<text x="150" y="190" class="sublabel">/mnt/zim/tour-comfy/stage</text>
<text x="150" y="210" class="sublabel">Input images</text>
<rect x="50" y="300" width="200" height="100" class="component"/>
<text x="150" y="320" class="label">Output Directory</text>
<text x="150" y="340" class="sublabel">/mnt/zim/tour-comfy/output</text>
<text x="150" y="360" class="sublabel">Processed images</text>
<rect x="50" y="450" width="200" height="100" class="component"/>
<text x="150" y="470" class="label">Failed Directory</text>
<text x="150" y="490" class="sublabel">/mnt/zim/tour-comfy/failed</text>
<text x="150" y="510" class="sublabel">Failed images</text>
<!-- System Services -->
<rect x="50" y="600" width="200" height="100" class="component"/>
<text x="150" y="620" class="label">System Services</text>
<text x="150" y="640" class="sublabel">comfyui.target</text>
<text x="150" y="660" class="sublabel">backend, api, watcher</text>
<!-- Connections -->
<line x1="350" y1="200" x2="850" y2="200" class="connection"/>
<text x="600" y="190" class="sublabel">Image Upload</text>
<line x1="350" y1="350" x2="850" y2="350" class="connection"/>
<text x="600" y="340" class="sublabel">Processing Request</text>
<line x1="350" y1="500" x2="850" y2="500" class="connection"/>
<text x="600" y="490" class="sublabel">Database Access</text>
<line x1="350" y1="650" x2="850" y2="650" class="connection"/>
<text x="600" y="640" class="sublabel">File Monitoring</text>
<!-- Arrowhead marker definition -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#757575"/>
</marker>
</defs>
<!-- Configuration Files -->
<rect x="400" y="50" width="200" height="80" class="component"/>
<text x="500" y="70" class="label">Configuration</text>
<text x="500" y="90" class="sublabel">config.json</text>
<text x="500" y="110" class="sublabel">API Settings</text>
<!-- Prompt Management -->
<rect x="400" y="200" width="200" height="80" class="component"/>
<text x="500" y="220" class="label">Prompt Management</text>
<text x="500" y="240" class="sublabel">prompt_pose_book.md</text>
<text x="500" y="260" class="sublabel">Base prompts</text>
<!-- GFPGAN Component -->
<rect x="400" y="350" width="200" height="80" class="component"/>
<text x="500" y="370" class="label">GFPGAN Face Restoration</text>
<text x="500" y="390" class="sublabel">gfpgan directory</text>
<text x="500" y="410" class="sublabel">Face swap functionality</text>
<!-- System Target -->
<rect x="400" y="600" width="200" height="80" class="component"/>
<text x="500" y="620" class="label">System Target</text>
<text x="500" y="640" class="sublabel">comfyui.target</text>
<text x="500" y="660" class="sublabel">Service orchestration</text>
<!-- Connection between system components -->
<line x1="600" y1="130" x2="600" y2="150" class="connection"/>
<line x1="600" y1="280" x2="600" y2="300" class="connection"/>
<line x1="600" y1="430" x2="600" y2="450" class="connection"/>
<line x1="600" y1="580" x2="600" y2="600" class="connection"/>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -0,0 +1,11 @@
#,Pose,Base,Key Action,Mood
9,**The Bridge**,Supine,"Hips lifted high, body arched","Strong, open"
10,**The Lotus (recline)**,Supine,"Legs crossed in lotus, knees open","Centered, tranquil"
11,**The Archer**,Side-lying,"Top leg bent, hand grasps ankle, bow shape","Tense, focused"
12,**The Crescent**,Kneeling/standing,"Deep backbend, arms overhead, hips forward","Expansive, vulnerable"
13,**The Cocoon**,Side-lying,"Fetal curl, arms wrapped around legs","Protected, intimate"
14,**The Swan Dive**,Prone,"Chest and legs lifted, arms forward","Graceful, soaring"
15,**The Suspended Split**,Supine,Legs vertical and spread wide in full split,"Flexible, exposed"
16,**The Embrace**,Seated,"Folded forward over legs, arms wrapped around","Intimate, yielding"
17,**The Cascade**,Seated,Twisted fold over one extended leg,"Fluid, surrendered"
18,**The Flame**,Standing,"One-legged balance, arms overhead","Poised, burning"
1 # Pose Base Key Action Mood
2 9 **The Bridge** Supine Hips lifted high, body arched Strong, open
3 10 **The Lotus (recline)** Supine Legs crossed in lotus, knees open Centered, tranquil
4 11 **The Archer** Side-lying Top leg bent, hand grasps ankle, bow shape Tense, focused
5 12 **The Crescent** Kneeling/standing Deep backbend, arms overhead, hips forward Expansive, vulnerable
6 13 **The Cocoon** Side-lying Fetal curl, arms wrapped around legs Protected, intimate
7 14 **The Swan Dive** Prone Chest and legs lifted, arms forward Graceful, soaring
8 15 **The Suspended Split** Supine Legs vertical and spread wide in full split Flexible, exposed
9 16 **The Embrace** Seated Folded forward over legs, arms wrapped around Intimate, yielding
10 17 **The Cascade** Seated Twisted fold over one extended leg Fluid, surrendered
11 18 **The Flame** Standing One-legged balance, arms overhead Poised, burning

57
AGENTS.md Normal file
View File

@@ -0,0 +1,57 @@
# Agents in Qwen-Image-Edit Rapid-AIO v23
This document describes the agents and components that make up the Qwen image editing system running on A6000 hardware.
## System Overview
The Qwen-Image-Edit Rapid-AIO v23 system is a headless image editing API that runs Phr00t's Rapid-AIO NSFW v23 model as a Q8 GGUF. The system consists of two main components:
1. ComfyUI backend that executes the Qwen models
2. FastAPI service that provides HTTP API endpoints
## Key Agents and Components
### 1. ComfyUI Backend Agent
- **Role**: Core image editing backend that executes the Qwen models
- **Service Name**: `comfyui-backend`
- **Port**: 8188
- **Components**:
- Qwen model (Qwen-Rapid-NSFW-v23_Q8_0.gguf)
- Text encoder (qwen_2.5_vl_7b_fp8_scaled.safetensors)
- VAE decoder (qwen_image_vae.safetensors)
- Workflow execution engine
### 2. FastAPI Edit Agent
- **Role**: Provides HTTP API endpoint for image editing requests
- **Service Name**: `comfyui-api`
- **Port**: 8500
- **Functionality**:
- Accepts image and prompt input via POST request
- Processes edits using ComfyUI backend
- Returns edited PNG output
## Communication Flow
1. Client sends POST request to FastAPI Edit Agent (port 8500) with image and prompt
2. FastAPI Edit Agent forwards request to ComfyUI Backend Agent (port 8188)
3. ComfyUI Backend Agent processes the image using Qwen models via workflow
4. Result is returned through FastAPI Edit Agent to client
## Service Management
### Backend Service
- **Service Name**: `comfyui-backend`
- **Port**: 8188
- **Command**: `/home/mike/dev/qwen-image-edit-rapid-aio-nsfw-v23/a6000-comfy/run_comfyui.sh`
### API Service
- **Service Name**: `comfyui-api`
- **Port**: 8500
- **Command**: `/home/mike/dev/qwen-image-edit-rapid-aio-nsfw-v23/a6000-comfy/start_api.sh`
## Configuration Notes
The system uses the following environment variables:
- COMFY_URL: Set to "http://127.0.0.1:8188"
- HOST: Set to "0.0.0.0"
- PORT: Set to "8500"
- MAX_AREA: Default 1048576 (1MP output budget)

93
architecture2.svg Normal file
View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg">
<style>
.component {
fill: #e6f3ff;
stroke: #003366;
stroke-width: 2;
rx: 10;
ry: 10;
}
.label {
font-family: Arial, sans-serif;
font-size: 14px;
font-weight: bold;
text-anchor: middle;
}
.sublabel {
font-family: Arial, sans-serif;
font-size: 12px;
text-anchor: middle;
}
.connection {
stroke: #003366;
stroke-width: 2;
marker-end: url(#arrowhead);
}
</style>
<!-- Arrowhead definition -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#003366"/>
</marker>
</defs>
<!-- FastAPI Edit Agent -->
<rect x="50" y="200" width="200" height="150" class="component"/>
<text x="150" y="220" class="label">FastAPI Edit Agent</text>
<text x="150" y="240" class="sublabel">Service: comfyui-api</text>
<text x="150" y="260" class="sublabel">Port: 8500</text>
<text x="150" y="280" class="sublabel">HTTP API Endpoint</text>
<!-- ComfyUI Backend Agent -->
<rect x="550" y="200" width="200" height="150" class="component"/>
<text x="650" y="220" class="label">ComfyUI Backend</text>
<text x="650" y="240" class="sublabel">Service: comfyui-backend</text>
<text x="650" y="260" class="sublabel">Port: 8188</text>
<text x="650" y="280" class="sublabel">Qwen Model Execution</text>
<!-- Client -->
<rect x="300" y="50" width="200" height="80" class="component"/>
<text x="400" y="70" class="label">Client</text>
<text x="400" y="90" class="sublabel">Image + Prompt Request</text>
<!-- Connection: Client to API -->
<line x1="400" y1="130" x2="150" y2="200" class="connection"/>
<!-- Connection: API to ComfyUI -->
<line x1="250" y1="275" x2="550" y2="275" class="connection"/>
<!-- Model Components inside ComfyUI -->
<rect x="570" y="370" width="160" height="100" class="component"/>
<text x="650" y="390" class="label">Qwen Model</text>
<text x="650" y="410" class="sublabel">Q8 GGUF (Qwen-Rapid-NSFW-v23)</text>
<!-- Text Encoder -->
<rect x="570" y="480" width="160" height="60" class="component"/>
<text x="650" y="500" class="label">Text Encoder</text>
<text x="650" y="520" class="sublabel">qwen_2.5_vl_7b_fp8_scaled.safetensors</text>
<!-- VAE Decoder -->
<rect x="570" y="550" width="160" height="60" class="component"/>
<text x="650" y="570" class="label">VAE Decoder</text>
<text x="650" y="590" class="sublabel">qwen_image_vae.safetensors</text>
<!-- Workflow -->
<rect x="350" y="370" width="120" height="80" class="component"/>
<text x="410" y="390" class="label">Workflow</text>
<text x="410" y="410" class="sublabel">workflow_qwen_edit.json</text>
<!-- Connection: API to Workflow -->
<line x1="250" y1="300" x2="410" y2="450" class="connection"/>
<!-- Connection: Workflow to Model -->
<line x1="470" y1="410" x2="570" y2="420" class="connection"/>
<!-- Connection: Model to VAE -->
<line x1="730" y1="420" x2="730" y2="560" class="connection"/>
<!-- Connection: VAE to Output -->
<line x1="730" y1="610" x2="150" y2="350" class="connection"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

235
architecturev2.svg Normal file
View File

@@ -0,0 +1,235 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="900">
<style>
.component {
fill: #e3f2fd;
stroke: #1976d2;
stroke-width: 2;
rx: 15;
ry: 15;
filter: url(#shadow);
}
.label {
font-family: Arial, sans-serif;
font-size: 16px;
font-weight: bold;
text-anchor: middle;
fill: #1a237e;
}
.sublabel {
font-family: Arial, sans-serif;
font-size: 12px;
text-anchor: middle;
fill: #333;
}
.connection {
stroke: #757575;
stroke-width: 2;
marker-end: url(#arrowhead);
}
.database {
fill: #ffebee;
stroke: #d32f2f;
stroke-width: 2;
rx: 15;
ry: 15;
filter: url(#shadow);
}
.service {
fill: #e8f5e9;
stroke: #388e3c;
stroke-width: 2;
rx: 15;
ry: 15;
filter: url(#shadow);
}
.system {
fill: #fff3e0;
stroke: #ef6c00;
stroke-width: 2;
rx: 15;
ry: 15;
filter: url(#shadow);
}
.title {
font-family: Arial, sans-serif;
font-size: 20px;
font-weight: bold;
text-anchor: middle;
fill: #1a237e;
}
.subtitle {
font-family: Arial, sans-serif;
font-size: 14px;
text-anchor: middle;
fill: #555;
}
.group-title {
font-family: Arial, sans-serif;
font-size: 16px;
font-weight: bold;
text-anchor: start;
fill: #1a237e;
}
</style>
<!-- Drop shadow filter -->
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="3" dy="3" stdDeviation="4" flood-color="#000000" flood-opacity="0.3"/>
</filter>
<!-- Arrowhead marker definition -->
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="0" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#757575"/>
</marker>
</defs>
<!-- Main system container -->
<rect x="50" y="50" width="1300" height="800" class="system"/>
<text x="700" y="40" class="title">Qwen Image Edit Rapid-AIO v23 System Architecture</text>
<text x="700" y="65" class="subtitle">Complete System Overview with All Components and Interactions</text>
<!-- Group Title: Input/Processing Pipeline -->
<text x="100" y="100" class="group-title">Input/Processing Pipeline</text>
<!-- Stage Directory -->
<rect x="100" y="120" width="200" height="100" class="component"/>
<text x="200" y="140" class="label">Stage Directory</text>
<text x="200" y="160" class="sublabel">/mnt/zim/tour-comfy/stage</text>
<text x="200" y="180" class="sublabel">Input images</text>
<!-- Output Directory -->
<rect x="100" y="250" width="200" height="100" class="component"/>
<text x="200" y="270" class="label">Output Directory</text>
<text x="200" y="290" class="sublabel">/mnt/zim/tour-comfy/output</text>
<text x="200" y="310" class="sublabel">Processed images</text>
<!-- Failed Directory -->
<rect x="100" y="380" width="200" height="100" class="component"/>
<text x="200" y="400" class="label">Failed Directory</text>
<text x="200" y="420" class="sublabel">/mnt/zim/tour-comfy/failed</text>
<text x="200" y="440" class="sublabel">Failed images</text>
<!-- Group Title: System Services -->
<text x="100" y="520" class="group-title">System Services</text>
<!-- System Target -->
<rect x="100" y="540" width="200" height="100" class="component"/>
<text x="200" y="560" class="label">System Target</text>
<text x="200" y="580" class="sublabel">comfyui.target</text>
<text x="200" y="600" class="sublabel">Service orchestration</text>
<!-- Group Title: Backend Components -->
<text x="500" y="100" class="group-title">Backend Components</text>
<!-- ComfyUI Backend Component -->
<rect x="500" y="120" width="250" height="120" class="component"/>
<text x="625" y="140" class="label">ComfyUI Backend</text>
<text x="625" y="160" class="sublabel">Qwen-Rapid-NSFW-v23</text>
<text x="625" y="180" class="sublabel">GGUF Model (21.8GB)</text>
<text x="625" y="200" class="sublabel">ComfyUI Workflow Engine</text>
<!-- GFPGAN Component -->
<rect x="500" y="270" width="250" height="100" class="component"/>
<text x="625" y="290" class="label">GFPGAN Face Restoration</text>
<text x="625" y="310" class="sublabel">gfpgan directory</text>
<text x="625" y="330" class="sublabel">Face swap functionality</text>
<!-- Prompt Management -->
<rect x="500" y="400" width="250" height="100" class="component"/>
<text x="625" y="420" class="label">Prompt Management</text>
<text x="625" y="440" class="sublabel">prompt_pose_book.md</text>
<text x="625" y="460" class="sublabel">Base prompts &amp; pose book</text>
<!-- Configuration Files -->
<rect x="500" y="530" width="250" height="100" class="component"/>
<text x="625" y="550" class="label">Configuration</text>
<text x="625" y="570" class="sublabel">config.json</text>
<text x="625" y="590" class="sublabel">API Settings &amp; Paths</text>
<!-- Group Title: API and Database -->
<text x="1000" y="100" class="group-title">API and Database</text>
<!-- FastAPI Service Component -->
<rect x="1000" y="120" width="250" height="100" class="service"/>
<text x="1125" y="140" class="label">FastAPI Service</text>
<text x="1125" y="160" class="sublabel">edit_api.py</text>
<text x="1125" y="180" class="sublabel">Image Processing API</text>
<!-- PostgreSQL Database -->
<rect x="1000" y="250" width="250" height="100" class="database"/>
<text x="1125" y="270" class="label">PostgreSQL Database</text>
<text x="1125" y="290" class="sublabel">host: 192.168.1.160</text>
<text x="1125" y="310" class="sublabel">port: 5433, dbname: dv</text>
<!-- Group Title: Watcher Service -->
<text x="1000" y="400" class="group-title">Watcher Service</text>
<!-- Folder Watcher Component -->
<rect x="1000" y="420" width="250" height="100" class="service"/>
<text x="1125" y="440" class="label">Folder Watcher</text>
<text x="1125" y="460" class="sublabel">watcher.py</text>
<text x="1125" y="480" class="sublabel">car.html Generator</text>
<!-- Group Title: System Integration -->
<text x="1000" y="550" class="group-title">System Integration</text>
<!-- System Services -->
<rect x="1000" y="570" width="250" height="100" class="component"/>
<text x="1125" y="590" class="label">System Services</text>
<text x="1125" y="610" class="sublabel">comfyui-backend.service</text>
<text x="1125" y="630" class="sublabel">comfyui-api.service</text>
<text x="1125" y="650" class="sublabel">comfyui-watcher.service</text>
<!-- Connections between components -->
<!-- Stage to ComfyUI Backend -->
<line x1="300" y1="170" x2="500" y2="170" class="connection"/>
<text x="400" y="160" class="sublabel">Image Upload</text>
<!-- ComfyUI Backend to Output Directory -->
<line x1="750" y1="180" x2="950" y2="180" class="connection"/>
<text x="850" y="170" class="sublabel">Processing Result</text>
<!-- ComfyUI Backend to Database -->
<line x1="750" y1="300" x2="1000" y2="300" class="connection"/>
<text x="875" y="290" class="sublabel">Database Access</text>
<!-- FastAPI to Database -->
<line x1="1250" y1="170" x2="1250" y2="300" class="connection"/>
<text x="1260" y="235" class="sublabel">Database Access</text>
<!-- Folder Watcher to Output Directory -->
<line x1="1250" y1="470" x2="950" y2="470" class="connection"/>
<text x="1100" y="460" class="sublabel">car.html Generation</text>
<!-- Folder Watcher to Database -->
<line x1="1250" y1="520" x2="1250" y2="350" class="connection"/>
<text x="1260" y="435" class="sublabel">Database Updates</text>
<!-- System Target to Services -->
<line x1="300" y1="590" x2="1000" y2="590" class="connection"/>
<text x="650" y="580" class="sublabel">Service Orchestration</text>
<!-- ComfyUI Backend to Prompt Management -->
<line x1="750" y1="240" x2="750" y2="400" class="connection"/>
<text x="760" y="320" class="sublabel">Prompt Usage</text>
<!-- ComfyUI Backend to GFPGAN -->
<line x1="750" y1="390" x2="750" y2="270" class="connection"/>
<text x="760" y="330" class="sublabel">Face Restoration</text>
<!-- System Target to ComfyUI Backend -->
<line x1="300" y1="590" x2="500" y2="180" class="connection"/>
<text x="400" y="500" class="sublabel">Start Backend</text>
<!-- System Target to FastAPI -->
<line x1="300" y1="590" x2="1000" y2="170" class="connection"/>
<text x="650" y="500" class="sublabel">Start API</text>
<!-- System Target to Watcher -->
<line x1="300" y1="590" x2="1000" y2="470" class="connection"/>
<text x="650" y="500" class="sublabel">Start Watcher</text>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

1
backlog.md Normal file
View File

@@ -0,0 +1 @@
privacy feature not work when overlay

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"api_url": "http://127.0.0.1:8500/edit",
"prompt": "high quality. detailed, female nude, high quality. realistic. detailed. female nude",
"prompt": "high quality. realistic. detailed, female nude. realistic, high quality. realistic. detailed. female nude. realistic",
"base_prompts": [
"Head-on a full-body three-quarter full-nude-body female portrait, realistic, transparent background",
"Head-on straight-on full-nude-body female portrait, realistic, transparent background",
@@ -19,5 +19,9 @@
"output_dir": "/mnt/zim/tour-comfy/output",
"failed_dir": "/mnt/zim/tour-comfy/failed",
"processed_file": "./tour-comfy/processed.json",
"log_file": "./tour-comfy/watcher.log"
"log_file": "./tour-comfy/watcher.log",
"wireframe_dir": "/mnt/zim/tour-comfy/wireframe",
"faceswap_model": "~/.insightface/models/inswapper_128.onnx",
"facefusion_dir": "~/facefusion",
"facefusion_venv": "~/facefusion-venv"
}

View File

@@ -26,6 +26,8 @@ def migrate_schema():
"ALTER TABLE person ADD COLUMN IF NOT EXISTS has_background BOOLEAN DEFAULT TRUE",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS source_refs TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS has_clothing BOOLEAN DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS content_type TEXT DEFAULT 'image'",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS faceswap_source_video TEXT",
]:
cur.execute(sql)
conn.commit()
@@ -36,15 +38,17 @@ def migrate_schema():
def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
embedding=None, clip_description=None, prompt=None, pose=None,
sort_order=None, group_name=None, hidden=None,
has_background=None, source_refs=None, has_clothing=None):
has_background=None, source_refs=None, has_clothing=None,
content_type=None, faceswap_source_video=None):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO person (filename, filepath, name, group_id, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
has_background, source_refs, has_clothing,
content_type, faceswap_source_video)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (filename) DO UPDATE
SET filepath = COALESCE(EXCLUDED.filepath, person.filepath),
name = COALESCE(EXCLUDED.name, person.name),
@@ -59,11 +63,14 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
hidden = COALESCE(EXCLUDED.hidden, person.hidden),
has_background = COALESCE(EXCLUDED.has_background, person.has_background),
source_refs = COALESCE(EXCLUDED.source_refs, person.source_refs),
has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing);
has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing),
content_type = COALESCE(EXCLUDED.content_type, person.content_type),
faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video);
""", (filename, filepath, name, group_id,
json.dumps(tags) if tags else None,
embedding, clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing))
has_background, source_refs, has_clothing,
content_type, faceswap_source_video))
conn.commit()
finally:
cur.close()
@@ -101,7 +108,7 @@ def list_persons():
cur.execute("""
SELECT filename, name, group_id, clip_description,
prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
has_clothing
has_clothing, content_type, faceswap_source_video
FROM person
""")
return cur.fetchall()

View File

@@ -32,6 +32,7 @@ from PIL import Image
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import shutil
import re
@@ -59,6 +60,8 @@ NODE_SAVE = "10"
MAX_SEED = 2**32 - 1
VIDEO_EXTENSIONS = ('.mp4', '.mov', '.avi', '.webm', '.mkv')
# Poses where the source image should be rotated 180° before pipeline for better results
ROTATE_180_POSES = {"the dragon", "dragon", "the draak", "draak"}
@@ -115,6 +118,18 @@ def _watch_car_html():
pass
def _load_wireframe_dir() -> str:
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
return conf.get("wireframe_dir", "/mnt/zim/tour-comfy/wireframe")
def _load_faceswap_model_path() -> str:
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
return os.path.expanduser(conf.get("faceswap_model", "~/.insightface/models/inswapper_128.onnx"))
@app.on_event("startup")
def on_startup():
try:
@@ -123,6 +138,14 @@ def on_startup():
print(f"DB migration warning: {e}")
_sync_car_html()
threading.Thread(target=_watch_car_html, daemon=True).start()
# Mount wireframe static dir for browser video preview
try:
wf_dir = _load_wireframe_dir()
if os.path.isdir(wf_dir):
app.mount("/wireframe", StaticFiles(directory=wf_dir), name="wireframe")
print(f"[wireframe] mounted {wf_dir} → /wireframe")
except Exception as e:
print(f"[wireframe] mount warning: {e}")
# --- helpers ----------------------------------------------------------------
@@ -311,6 +334,379 @@ def _apply_transparency(png_bytes: bytes) -> bytes:
return png_bytes
# --- faceswapper (insightface + inswapper_128) --------------------------------
# Setup: pip install insightface onnxruntime-gpu opencv-python-headless
# Download: place inswapper_128.onnx at ~/.insightface/models/inswapper_128.onnx
# Source: https://huggingface.co/deepinsight/inswapper
_faceswapper = None
_faceswapper_lock = threading.Lock()
_gfpgan = None
_gfpgan_lock = threading.Lock()
def _load_faceswapper():
global _faceswapper
if _faceswapper is not None:
return _faceswapper
with _faceswapper_lock:
if _faceswapper is not None:
return _faceswapper
try:
import insightface
from insightface.app import FaceAnalysis
except ImportError:
raise RuntimeError("insightface not installed. Run: pip install insightface onnxruntime-gpu")
providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
app = FaceAnalysis(name='buffalo_l', providers=providers)
app.prepare(ctx_id=0, det_size=(640, 640))
model_path = _load_faceswap_model_path()
if not os.path.exists(model_path):
# Try HuggingFace download as fallback
try:
import huggingface_hub
model_path = huggingface_hub.hf_hub_download(
'deepinsight/inswapper', 'inswapper_128.onnx',
local_dir=os.path.dirname(model_path),
)
print(f"[faceswap] Downloaded inswapper_128.onnx to {model_path}")
except Exception as de:
raise RuntimeError(
f"inswapper_128.onnx not found at {model_path}. "
f"Download from https://huggingface.co/deepinsight/inswapper and place it there. "
f"Download error: {de}"
)
swapper = insightface.model_zoo.get_model(model_path, providers=providers)
_faceswapper = (app, swapper)
print(f"[faceswap] loaded insightface buffalo_l + inswapper_128 from {model_path}")
return _faceswapper
def _load_gfpgan():
"""Lazy-load GFPGAN face restorer. Returns restorer or False if unavailable."""
global _gfpgan
if _gfpgan is not None:
return _gfpgan
with _gfpgan_lock:
if _gfpgan is not None:
return _gfpgan
try:
from gfpgan import GFPGANer
# Main GFPGAN model
model_path = os.path.expanduser('~/.gfpgan/weights/GFPGANv1.4.pth')
os.makedirs(os.path.dirname(model_path), exist_ok=True)
if not os.path.exists(model_path):
import urllib.request
print('[gfpgan] Downloading GFPGANv1.4.pth (~333 MB)...')
tmp = model_path + '.tmp'
urllib.request.urlretrieve(
'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.4/GFPGANv1.4.pth',
tmp
)
os.rename(tmp, model_path)
# GFPGANer hardcodes facexlib download path to CWD/gfpgan/weights/
# → change CWD to ~ so models land at ~/gfpgan/weights/ (stable across runs)
home = os.path.expanduser('~')
os.makedirs(os.path.join(home, 'gfpgan', 'weights'), exist_ok=True)
_orig_cwd = os.getcwd()
os.chdir(home)
try:
restorer = GFPGANer(model_path=model_path, upscale=1, arch='clean',
channel_multiplier=2, bg_upsampler=None)
finally:
os.chdir(_orig_cwd)
_gfpgan = restorer
print('[gfpgan] GFPGANv1.4 loaded')
except Exception as e:
print(f'[gfpgan] not available: {e}')
_gfpgan = False
return _gfpgan
def _make_video_poster(video_path: str) -> str | None:
"""Extract a poster JPG (sibling `<stem>.jpg`) so the gallery can show a
thumbnail for a video via a plain <img> (file:// can't render <video> as a
thumb). Returns the poster path on success, else None."""
import subprocess
poster_path = os.path.splitext(video_path)[0] + '.jpg'
try:
r = subprocess.run([
'ffmpeg', '-y', '-ss', '1', '-i', video_path,
'-frames:v', '1', '-q:v', '3', poster_path,
], capture_output=True, timeout=120)
if r.returncode == 0 and os.path.exists(poster_path):
return poster_path
# -ss 1 can overshoot very short clips; retry from the first frame
r = subprocess.run([
'ffmpeg', '-y', '-i', video_path,
'-frames:v', '1', '-q:v', '3', poster_path,
], capture_output=True, timeout=120)
if r.returncode == 0 and os.path.exists(poster_path):
return poster_path
except Exception as pe:
print(f'[poster] failed for {video_path}: {pe}')
return None
def _faceswap_worker(job_id: str, model_filename: str, video_name: str, enhance: bool = True):
"""Frame-by-frame face swap: model face → every face in template video."""
output_dir = _load_output_dir()
wireframe_dir = _load_wireframe_dir()
try:
import cv2
import numpy as np
app, swapper = _load_faceswapper()
except Exception as e:
jobs[job_id]["status"] = "error"
jobs[job_id]["error"] = str(e)
return
gfpgan_restorer = None
if enhance:
try:
r = _load_gfpgan()
if r is not False:
gfpgan_restorer = r
except Exception:
pass
try:
# 1. Load source (model) face
src_path = os.path.join(output_dir, model_filename)
src_bgr = cv2.imread(src_path)
if src_bgr is None:
raise ValueError(f"Cannot read model image: {model_filename}")
src_faces = app.get(src_bgr)
if not src_faces:
raise ValueError(f"No face detected in: {model_filename}")
# Use the largest face as source
src_face = max(src_faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
# 2. Open template video
video_path = os.path.join(wireframe_dir, video_name)
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise ValueError(f"Cannot open video: {video_name}")
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
jobs[job_id]["total"] = max(total, 1)
# 3. Write frame-swapped temp video
ts = time.strftime("%Y%m%d_%H%M%S")
vid_stem = os.path.splitext(video_name)[0]
base_name = naming.get_base_name(model_filename)
tmp_name = f"{ts}_fs_tmp_{vid_stem}_{base_name}.mp4"
out_name = f"{ts}_fs_{vid_stem}_{base_name}.mp4"
tmp_path = os.path.join(output_dir, tmp_name)
out_path = os.path.join(output_dir, out_name)
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
writer = cv2.VideoWriter(tmp_path, fourcc, fps, (width, height))
frame_idx = 0
while True:
ret, frame = cap.read()
if not ret:
break
tgt_faces = app.get(frame)
result = frame
if tgt_faces:
result = frame.copy()
for face in tgt_faces:
try:
result = swapper.get(result, face, src_face, paste_back=True)
except Exception:
pass
if gfpgan_restorer is not None:
try:
_, _, result = gfpgan_restorer.enhance(
result, has_aligned=False, only_center_face=False, paste_back=True
)
except Exception:
pass
writer.write(result)
frame_idx += 1
jobs[job_id]["done"] = frame_idx
cap.release()
writer.release()
# 4. Remux with original audio via ffmpeg
try:
import subprocess
r = subprocess.run([
'ffmpeg', '-y',
'-i', tmp_path,
'-i', video_path,
'-map', '0:v:0', '-map', '1:a?',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '18',
'-c:a', 'aac', '-movflags', '+faststart',
out_path,
], capture_output=True, timeout=600)
if r.returncode == 0:
os.remove(tmp_path)
else:
os.rename(tmp_path, out_path)
print(f"[faceswap] ffmpeg failed ({r.returncode}), using raw mp4v output")
except Exception as fe:
os.rename(tmp_path, out_path)
print(f"[faceswap] ffmpeg error: {fe}")
# 5. Snapshot poster + register output in DB under same group as model
_make_video_poster(out_path)
person = database.get_person(model_filename)
group_id = (person[1] if person and person[1] else naming.get_base_name(model_filename))
database.upsert_person(
out_name,
filepath=out_path,
group_id=group_id,
content_type='video',
faceswap_source_video=video_name,
source_refs=json.dumps([model_filename]),
)
jobs[job_id]["status"] = "done"
jobs[job_id]["output"] = out_name
except Exception as e:
print(f"[faceswap] error: {e}")
jobs[job_id]["status"] = "error"
jobs[job_id]["error"] = str(e)
def _faceswap_worker_ff(job_id: str, model_filename: str, video_name: str,
hair: bool = True, enhance: bool = True):
"""High-quality faceswap via FaceFusion CLI (supports hair_swapper + ghost model)."""
import subprocess as sp
import sys
import re as _re
output_dir = _load_output_dir()
wireframe_dir = _load_wireframe_dir()
with open(CONFIG_PATH, 'r') as f:
conf = json.load(f)
ff_dir = os.path.expanduser(conf.get('facefusion_dir', '~/facefusion'))
ff_venv = os.path.expanduser(conf.get('facefusion_venv', '~/facefusion-venv'))
ff_script = os.path.join(ff_dir, 'facefusion.py')
ff_py = os.path.join(ff_venv, 'bin', 'python')
if not os.path.exists(ff_py):
ff_py = sys.executable
if not os.path.exists(ff_script):
jobs[job_id]['status'] = 'error'
jobs[job_id]['error'] = (
f'FaceFusion not found at {ff_dir}. '
'Run: bash tour-comfy/install_facefusion.sh'
)
return
src_path = os.path.join(output_dir, model_filename)
video_path = os.path.join(wireframe_dir, video_name)
ts = time.strftime('%Y%m%d_%H%M%S')
vid_stem = os.path.splitext(video_name)[0]
base_name = naming.get_base_name(model_filename)
out_name = f'{ts}_fs_{vid_stem}_{base_name}.mp4'
out_path = os.path.join(output_dir, out_name)
processors = ['face_swapper']
# hair_swapper is not available in this FaceFusion version; use face_enhancer for quality
if enhance:
processors.append('face_enhancer')
cmd = [
ff_py, ff_script, 'headless-run',
'--source-paths', src_path,
'--target-path', video_path,
'--output-path', out_path,
'--processors', *processors,
'--execution-providers', 'cuda',
'--face-swapper-model', 'ghost_3_256',
# The default yolo_face detector at score 0.5 misses the extreme-angle /
# cropped close-up faces common in these POV template clips, so the swap
# silently no-ops. scrfd at a lower score + multi-angle detection reliably
# finds them; 'many' selector swaps every detected face per frame.
'--face-detector-model', 'scrfd',
'--face-detector-score', '0.3',
'--face-detector-angles', '0', '90', '270',
'--face-selector-mode', 'many',
]
if enhance:
cmd += ['--face-enhancer-model', 'gfpgan_1.4']
jobs[job_id]['total'] = 100
jobs[job_id]['done'] = 0
# Ensure CUDA libs are on LD_LIBRARY_PATH for the subprocess (inherited from parent,
# but also add nvidia package libs as fallback if running outside start_api.sh)
import site as _site
_sp_pkgs = next((p for p in _site.getsitepackages() if 'site-packages' in p), '')
_nv_base = os.path.join(_sp_pkgs, 'nvidia')
_extra_libs = ':'.join(
os.path.join(_nv_base, pkg, 'lib')
for pkg in ('cuda_runtime', 'cublas', 'cudnn', 'curand', 'cufft', 'cusolver', 'cusparse', 'nvjitlink', 'cuda_nvrtc')
if os.path.isdir(os.path.join(_nv_base, pkg, 'lib'))
)
_env = os.environ.copy()
if _extra_libs:
_env['LD_LIBRARY_PATH'] = _extra_libs + (':' + _env['LD_LIBRARY_PATH'] if _env.get('LD_LIBRARY_PATH') else '')
try:
output_lines = []
proc = sp.Popen(
cmd, cwd=ff_dir, env=_env,
stdout=sp.PIPE, stderr=sp.PIPE,
text=True, errors='replace',
)
# Read stdout for progress, stderr for error info
import threading as _thr
def _drain_stderr():
for ln in proc.stderr:
output_lines.append(ln.rstrip())
print(f'[facefusion] {ln.rstrip()}')
_thr.Thread(target=_drain_stderr, daemon=True).start()
for line in proc.stdout:
print(f'[facefusion] {line.rstrip()}')
m = _re.search(r'(\d+)\s*/\s*(\d+)', line)
if m:
done, total = int(m.group(1)), int(m.group(2))
if total > 0:
jobs[job_id]['done'] = done
jobs[job_id]['total'] = total
proc.wait()
if proc.returncode != 0:
tail = '\n'.join(output_lines[-10:])
raise RuntimeError(f'FaceFusion exited with code {proc.returncode}: {tail}')
if not os.path.exists(out_path):
raise RuntimeError('FaceFusion produced no output file')
_make_video_poster(out_path)
person = database.get_person(model_filename)
group_id = (person[1] if person and person[1] else naming.get_base_name(model_filename))
database.upsert_person(
out_name, filepath=out_path, group_id=group_id,
content_type='video', faceswap_source_video=video_name,
source_refs=json.dumps([model_filename]),
)
jobs[job_id]['status'] = 'done'
jobs[job_id]['output'] = out_name
except Exception as e:
print(f'[faceswap-ff] error: {e}')
jobs[job_id]['status'] = 'error'
jobs[job_id]['error'] = str(e)
# --- pipeline helper ---------------------------------------------------------
def _load_poses():
@@ -691,17 +1087,18 @@ def get_batch(job_id: str):
@app.get("/images")
def list_images():
output_dir = _load_output_dir()
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
all_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg') + VIDEO_EXTENSIONS
try:
# Try to get from DB first
try:
persons = database.list_persons()
# persons: (filename, name, group_id, clip_description, prompt, pose, sort_order, group_name, hidden, has_background, source_refs, has_clothing)
# list_persons cols: filename, name, group_id, clip_description,
# prompt, pose, sort_order, group_name, hidden, has_background,
# source_refs, has_clothing, content_type, faceswap_source_video
db_images = []
for p in persons:
fpath = os.path.join(output_dir, p[0])
if not os.path.exists(fpath):
continue # skip orphan DB records whose file no longer exists
continue
db_images.append({
"filename": p[0],
"name": p[1],
@@ -715,6 +1112,8 @@ def list_images():
"has_background": bool(p[9]) if p[9] is not None else True,
"source_refs": p[10],
"has_clothing": p[11],
"content_type": p[12] or "image",
"faceswap_source_video":p[13],
})
db_images.sort(
key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])),
@@ -723,14 +1122,181 @@ def list_images():
return {"images": db_images}
except Exception as db_err:
print(f"DB error in list_images: {db_err}")
# Fallback to filesystem
files = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions)]
listing = os.listdir(output_dir)
# video poster snapshots share a video sibling's stem — don't list them as items
video_stems = {os.path.splitext(f)[0] for f in listing if f.lower().endswith(VIDEO_EXTENSIONS)}
files = [
f for f in listing
if f.lower().endswith(all_extensions)
and not (f.lower().endswith('.jpg') and os.path.splitext(f)[0] in video_stems)
]
files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
return {"images": [{"filename": f} for f in files]}
except Exception as e:
raise HTTPException(500, str(e))
@app.get("/videos")
def list_videos():
"""Return available wireframe template videos."""
wireframe_dir = _load_wireframe_dir()
if not os.path.isdir(wireframe_dir):
return {"videos": []}
videos = [
f for f in sorted(os.listdir(wireframe_dir))
if f.lower().endswith(VIDEO_EXTENSIONS) and not f.startswith('.')
]
return {"videos": videos, "wireframe_dir": wireframe_dir}
@app.get("/wireframe/duration/{video_name}")
def wireframe_duration(video_name: str):
"""Return duration (seconds) of a wireframe video via ffprobe."""
import subprocess
wireframe_dir = _load_wireframe_dir()
video_path = os.path.join(wireframe_dir, video_name)
if not os.path.exists(video_path):
raise HTTPException(404, f"Video not found: {video_name}")
try:
r = subprocess.run(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
'-of', 'json', video_path],
capture_output=True, timeout=10,
)
info = json.loads(r.stdout)
duration = float(info.get('format', {}).get('duration', 0))
except Exception as e:
raise HTTPException(500, f"ffprobe error: {e}")
return {'video_name': video_name, 'duration': duration}
class TrimRequest(BaseModel):
video_name: str
start: float # seconds
end: float # seconds
output_name: str | None = None # auto-generated if None
@app.post("/wireframe/trim")
def trim_wireframe(req: TrimRequest):
"""Trim a wireframe video to [start, end] seconds using ffmpeg stream copy."""
import subprocess
wireframe_dir = _load_wireframe_dir()
video_path = os.path.join(wireframe_dir, req.video_name)
if not os.path.exists(video_path):
raise HTTPException(404, f"Video not found: {req.video_name}")
if req.start < 0 or req.end <= req.start:
raise HTTPException(400, "Invalid start/end: end must be > start ≥ 0")
stem = os.path.splitext(req.video_name)[0]
if req.output_name:
out_name = req.output_name if req.output_name.lower().endswith('.mp4') else req.output_name + '.mp4'
else:
out_name = f"{stem}_{int(req.start)}s-{int(req.end)}s.mp4"
out_path = os.path.join(wireframe_dir, out_name)
if os.path.exists(out_path):
raise HTTPException(409, f"File already exists: {out_name}")
r = subprocess.run(
['ffmpeg', '-y',
'-ss', str(req.start), '-to', str(req.end),
'-i', video_path,
'-c', 'copy',
out_path],
capture_output=True, timeout=120,
)
if r.returncode != 0:
raise HTTPException(500, f"ffmpeg error: {r.stderr.decode(errors='replace')[:500]}")
return {'output_name': out_name, 'start': req.start, 'end': req.end}
class FrameExtractRequest(BaseModel):
video_name: str
time: float = 0.0
@app.post("/wireframe/frame")
def wireframe_extract_frame(req: FrameExtractRequest):
"""Extract a single frame at a given timestamp and return it as base64 PNG."""
import base64
wireframe_dir = _load_wireframe_dir()
video_path = os.path.join(wireframe_dir, req.video_name)
if not os.path.exists(video_path):
raise HTTPException(404, f"Video not found: {req.video_name}")
try:
img = _extract_frame_at(video_path, req.time)
except Exception as e:
raise HTTPException(500, str(e))
buf = io.BytesIO()
img.save(buf, format="PNG")
return {"frame_b64": base64.b64encode(buf.getvalue()).decode()}
class FaceswapRequest(BaseModel):
model_filename: str # image from output_dir to use as face source
video_name: str # filename of template video in wireframe_dir
enhance: bool = True # GFPGAN face restoration after each frame swap
hair: bool = False # use FaceFusion with hair_swapper (requires FaceFusion install)
@app.get("/faceswap/check")
def faceswap_check():
"""Report which enhancement backends are available."""
gfpgan_ok = False
try:
import gfpgan # noqa
gfpgan_ok = True
except ImportError:
pass
with open(CONFIG_PATH, 'r') as f:
conf = json.load(f)
ff_dir = os.path.expanduser(conf.get('facefusion_dir', '~/facefusion'))
ff_script = os.path.join(ff_dir, 'facefusion.py')
return {'gfpgan': gfpgan_ok, 'facefusion': os.path.exists(ff_script)}
@app.post("/faceswap")
def start_faceswap(req: FaceswapRequest):
output_dir = _load_output_dir()
wireframe_dir = _load_wireframe_dir()
src_path = os.path.join(output_dir, req.model_filename)
video_path = os.path.join(wireframe_dir, req.video_name)
if not os.path.exists(src_path):
raise HTTPException(404, f"Model image not found: {req.model_filename}")
if not os.path.exists(video_path):
raise HTTPException(404, f"Template video not found: {req.video_name}")
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {
"status": "running", "type": "faceswap",
"total": 1, "done": 0, "failed": 0,
"model": req.model_filename, "video": req.video_name,
}
if req.hair:
t = threading.Thread(
target=_faceswap_worker_ff,
args=(job_id, req.model_filename, req.video_name),
kwargs={'hair': True, 'enhance': req.enhance},
daemon=True,
)
else:
t = threading.Thread(
target=_faceswap_worker,
args=(job_id, req.model_filename, req.video_name),
kwargs={'enhance': req.enhance},
daemon=True,
)
t.start()
return {"job_id": job_id, "model": req.model_filename, "video": req.video_name}
# --- tagging routes ----------------------------------------------------------
class TagRequest(BaseModel):
@@ -1167,6 +1733,203 @@ def remove_background_group(group_id: str, background_tasks: BackgroundTasks):
return {"status": "processing", "group_id": group_id}
# --- scenery generation -------------------------------------------------------
def _extract_frame_at(video_path: str, t: float) -> Image.Image:
"""Extract a single frame at time t (seconds) from a video via ffmpeg."""
import subprocess as _sp
r = _sp.run(
['ffmpeg', '-y', '-ss', str(t), '-i', video_path,
'-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', 'pipe:1'],
capture_output=True, timeout=15,
)
if r.returncode != 0 or not r.stdout:
raise ValueError(f"ffmpeg frame extract failed: {r.stderr.decode(errors='replace')[:300]}")
return Image.open(io.BytesIO(r.stdout)).convert("RGB")
class SceneryRequest(BaseModel):
model_filename: str # person image in output_dir
scene_bytes: str | None = None # base64-encoded PNG/JPEG of the reference scene
scene_video: str | None = None # wireframe video name to extract frame from
scene_time: float = 0.0 # timestamp (seconds) to extract from video
prompt: str | None = None # override; auto-built if None
seed: int = -1
def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
prompt: str, seed: int):
output_dir = _load_output_dir()
try:
model_path = os.path.join(output_dir, model_filename)
model_pil = Image.open(model_path).convert("RGB")
png_bytes = _run_pipeline(model_pil, prompt, seed, MAX_AREA,
extra_images=[scene_pil])
ts = time.strftime("%Y%m%d_%H%M%S")
base_name = naming.get_base_name(model_filename)
out_name = f"{ts}_sc_{base_name}"
out_path = os.path.join(output_dir, out_name)
with open(out_path, "wb") as f:
f.write(png_bytes)
person = database.get_person(model_filename)
group_id = person[1] if person and person[1] else naming.get_base_name(model_filename)
try:
embedding = embeddings.generate_embedding(out_path)
next_order = database.get_next_sort_order(group_id)
database.upsert_person(out_name, filepath=out_path, embedding=embedding,
group_id=group_id, prompt=prompt,
sort_order=next_order,
source_refs=json.dumps([model_filename]))
except Exception as db_err:
print(f"[scenery] DB error: {db_err}")
jobs[job_id]["status"] = "done"
jobs[job_id]["output"] = out_name
except Exception as e:
print(f"[scenery] error: {e}")
jobs[job_id]["status"] = "error"
jobs[job_id]["error"] = str(e)
@app.post("/generate-scenery")
def generate_scenery(req: SceneryRequest):
output_dir = _load_output_dir()
wireframe_dir = _load_wireframe_dir()
model_path = os.path.join(output_dir, req.model_filename)
if not os.path.exists(model_path):
raise HTTPException(404, f"Model image not found: {req.model_filename}")
# Resolve scene image
if req.scene_bytes:
import base64
raw = base64.b64decode(req.scene_bytes)
scene_pil = Image.open(io.BytesIO(raw)).convert("RGB")
elif req.scene_video:
video_path = os.path.join(wireframe_dir, req.scene_video)
if not os.path.exists(video_path):
raise HTTPException(404, f"Scene video not found: {req.scene_video}")
try:
scene_pil = _extract_frame_at(video_path, req.scene_time)
except Exception as e:
raise HTTPException(500, f"Frame extraction failed: {e}")
else:
raise HTTPException(400, "Provide scene_bytes or scene_video")
prompt = req.prompt or (
"Place this person into the provided scene. Keep the person's appearance, "
"lighting, and pose natural and consistent with the environment. "
"Photorealistic, high quality."
)
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
threading.Thread(
target=_scenery_worker,
args=(job_id, req.model_filename, scene_pil, prompt, req.seed),
daemon=True,
).start()
return {"job_id": job_id, "model": req.model_filename}
# --- SAM2 background removal --------------------------------------------------
_sam2_predictor = None
_sam2_predictor_lock = threading.Lock()
def _load_sam2():
"""Lazy-load SAM2 AutomaticMaskGenerator. Returns generator or False if unavailable."""
global _sam2_predictor
if _sam2_predictor is not None:
return _sam2_predictor
with _sam2_predictor_lock:
if _sam2_predictor is not None:
return _sam2_predictor
try:
from sam2.build_sam import build_sam2
from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator
with open(CONFIG_PATH) as f:
conf = json.load(f)
ckpt = os.path.expanduser(conf.get("sam2_checkpoint", "~/.sam2/sam2.1_hiera_tiny.pt"))
cfg = conf.get("sam2_config", "configs/sam2.1/sam2.1_hiera_t.yaml")
if not os.path.exists(ckpt):
raise FileNotFoundError(f"SAM2 checkpoint not found: {ckpt}")
model = build_sam2(cfg, ckpt, device="cuda")
_sam2_predictor = SAM2AutomaticMaskGenerator(model)
print(f"[sam2] loaded from {ckpt}")
except Exception as e:
print(f"[sam2] not available: {e}")
_sam2_predictor = False
return _sam2_predictor
def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
"""Remove background using SAM2 (largest-area mask = subject), fallback to rembg."""
predictor = _load_sam2()
if predictor is False:
return _apply_transparency(png_bytes)
try:
import numpy as np
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
arr = np.array(img)
masks = predictor.generate(arr)
if not masks:
return _apply_transparency(png_bytes)
best = max(masks, key=lambda m: m["area"])
mask_np = (best["segmentation"].astype(np.uint8) * 255)
rgba = img.convert("RGBA")
r, g, b, _ = rgba.split()
alpha = Image.fromarray(mask_np, mode="L")
out = Image.merge("RGBA", (r, g, b, alpha))
buf = io.BytesIO()
out.save(buf, format="PNG")
return buf.getvalue()
except Exception as e:
print(f"[sam2] inference error, falling back to rembg: {e}")
return _apply_transparency(png_bytes)
@app.post("/remove-background-sam/{filename}")
def remove_background_sam(filename: str):
"""SAM2-based background removal (RGBA PNG). Falls back to rembg if SAM2 unavailable."""
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
path = person[5]
with open(path, "rb") as f:
png_bytes = f.read()
transparent_png = _apply_transparency_sam2(png_bytes)
with open(path, "wb") as f:
f.write(transparent_png)
used_sam2 = _sam2_predictor is not False and _sam2_predictor is not None
return {"status": "success", "filename": filename, "used_sam2": used_sam2}
@app.post("/restore-background/{filename}")
def restore_background(filename: str):
"""Flatten RGBA → RGB (white composite), making the image opaque again."""
person = database.get_person(filename)
if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found")
path = person[5]
img = Image.open(path)
if img.mode == "RGBA":
bg = Image.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
buf = io.BytesIO()
bg.save(buf, format="PNG")
with open(path, "wb") as f:
f.write(buf.getvalue())
return {"status": "success", "filename": filename}
@app.get("/sam2/check")
def sam2_check():
"""Return whether SAM2 is available."""
predictor = _load_sam2()
return {"sam2": predictor is not False and predictor is not None}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Install FaceFusion 3.x for high-quality face+hair swap.
# Clones into ~/facefusion and creates a dedicated venv at ~/facefusion-venv.
# Usage: bash tour-comfy/install_facefusion.sh
set -e
FF_DIR="${FACEFUSION_DIR:-$HOME/facefusion}"
FF_VENV="${FACEFUSION_VENV:-$HOME/facefusion-venv}"
FF_REPO="https://github.com/facefusion/facefusion"
echo "[facefusion] Installing to $FF_DIR (venv: $FF_VENV)"
# 1. Clone or update
if [ -d "$FF_DIR/.git" ]; then
echo "[facefusion] Updating existing clone ..."
git -C "$FF_DIR" pull --ff-only
else
echo "[facefusion] Cloning $FF_REPO ..."
git clone "$FF_REPO" "$FF_DIR"
fi
# 2. Create dedicated venv (avoids dependency conflicts with ComfyUI)
if [ ! -d "$FF_VENV" ]; then
echo "[facefusion] Creating venv at $FF_VENV ..."
python3 -m venv "$FF_VENV"
fi
PIP="$FF_VENV/bin/pip"
PY="$FF_VENV/bin/python"
"$PIP" install --upgrade pip wheel
# 3. Install FaceFusion requirements
cd "$FF_DIR"
"$PIP" install -r requirements.txt \
--extra-index-url https://download.pytorch.org/whl/cu124
# 4. Download base models (ghost_3_1_256 + gfpgan_1.4 for enhance)
echo "[facefusion] Downloading default models via FaceFusion model manager ..."
"$PY" facefusion.py \
--processors face_swapper hair_swapper face_enhancer \
--face-swapper-model ghost_3_1_256 \
--face-enhancer-model gfpgan_1.4 \
--execution-providers cpu \
download-models 2>/dev/null || true
echo ""
echo "[facefusion] Installation complete."
echo " Binary: $PY $FF_DIR/facefusion.py"
echo " Config: set facefusion_dir/facefusion_venv in tour-comfy/config.json"
echo ""
echo "Restart the API (start_api.sh) and the 'Hair swap' toggle will activate."

View File

@@ -0,0 +1,83 @@
#!/bin/bash
# Install GFPGAN face enhancement into the ComfyUI venv.
# Applies two patches for Python 3.13 + newer torchvision compatibility.
# Usage: bash tour-comfy/install_gfpgan.sh
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
source "$SCRIPT_DIR/env.sh"
source "$VENV/bin/activate"
PYTHON="$VENV/bin/python"
PIP="$VENV/bin/pip"
echo "[gfpgan] Step 1 — install basicsr (with Python 3.13 patch) ..."
TMPDIR=$(mktemp -d)
curl -sL "https://pypi.io/packages/source/b/basicsr/basicsr-1.4.2.tar.gz" -o "$TMPDIR/basicsr-1.4.2.tar.gz"
tar -xzf "$TMPDIR/basicsr-1.4.2.tar.gz" -C "$TMPDIR"
# Patch 1: fix get_version() — exec() doesn't update locals() in Python 3
"$PYTHON" - <<'PYPATCH'
import sys, re
setup = sys.argv[1]
with open(setup) as f:
content = f.read()
old = "def get_version():\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'))\n return locals()['__version__']"
new = "def get_version():\n if not os.path.exists(version_file):\n write_version_py()\n globs = {}\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'), globs)\n return globs['__version__']"
if old in content:
content = content.replace(old, new)
with open(setup, 'w') as f:
f.write(content)
print(' Patched setup.py get_version()')
else:
print(' setup.py pattern not found, skipping patch')
PYPATCH
"$TMPDIR/basicsr-1.4.2/setup.py" -- "$TMPDIR/basicsr-1.4.2/setup.py" 2>/dev/null || true
"$PYTHON" - "$TMPDIR/basicsr-1.4.2/setup.py" <<'PYPATCH'
import sys, re, os
setup = sys.argv[1]
with open(setup) as f:
content = f.read()
old = "def get_version():\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'))\n return locals()['__version__']"
new = "def get_version():\n if not os.path.exists(version_file):\n write_version_py()\n globs = {}\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'), globs)\n return globs['__version__']"
if old in content:
content = content.replace(old, new)
with open(setup, 'w') as f:
f.write(content)
print(' Patched setup.py get_version()')
else:
print(' setup.py already patched or pattern changed, skipping')
PYPATCH
"$PIP" install "$TMPDIR/basicsr-1.4.2/" --no-build-isolation --no-deps -q
rm -rf "$TMPDIR"
echo "[gfpgan] Step 2 — install facexlib and gfpgan ..."
"$PIP" install facexlib gfpgan -q
# Patch 2: fix torchvision functional_tensor import (removed in newer torchvision)
DEGR_PY="$VENV/lib/python3.13/site-packages/basicsr/data/degradations.py"
if [ -f "$DEGR_PY" ]; then
if grep -q "functional_tensor" "$DEGR_PY"; then
sed -i 's/from torchvision.transforms.functional_tensor import rgb_to_grayscale/from torchvision.transforms.functional import rgb_to_grayscale/' "$DEGR_PY"
echo "[gfpgan] Patched degradations.py functional_tensor import"
fi
fi
# Pre-download the model
MODEL_DIR="$HOME/.gfpgan/weights"
MODEL_PATH="$MODEL_DIR/GFPGANv1.4.pth"
mkdir -p "$MODEL_DIR"
if [ ! -f "$MODEL_PATH" ]; then
echo "[gfpgan] Downloading GFPGANv1.4.pth (~333 MB) ..."
wget -q --show-progress \
"https://github.com/TencentARC/GFPGAN/releases/download/v1.3.4/GFPGANv1.4.pth" \
-O "$MODEL_PATH.tmp"
mv "$MODEL_PATH.tmp" "$MODEL_PATH"
echo "[gfpgan] Model saved to $MODEL_PATH"
else
echo "[gfpgan] Model already present: $MODEL_PATH"
fi
echo ""
echo "[gfpgan] Done. Restart the API (start_api.sh) to enable face enhancement."

View File

@@ -7,7 +7,7 @@ realistic, transparent background
# Feminine
1 female nude.
Hyper realistic.
realistic.
# Upscale HQ Feminine

View File

@@ -0,0 +1,85 @@
# Prompt-Pose Book — Sample Set
> Pure pose and anatomy descriptors. Lighting, nudity, and background are derived from the input image.
---
# Butterfly (recline):
lying flat on back in reclined butterfly pose.
soles of feet pressed together, knees relaxed and falling open outward to sides, hips fully open.
arms resting loosely overhead or at sides.
Looking directly into camera. Serene, open.
Perfect anatomy, realistic
---
# Celestial:
lying on back.
arms extended wide to sides in celestial spread.
legs raised toward torso, crossed at ankles, knees deeply bent, drawn close to chest.
hands reach inward to clasp around feet.
settled back slightly, showcasing deep knee bend and intricate hand-foot clasp.
Looking directly into camera. Ethereal, serene.
Perfect anatomy, realistic
---
# Grounded vs. Vulnerable S-Curve:
kneeling on all fours, hands planted under shoulders, knees under hips.
one side grounded and stable — arm and leg firmly planted, muscles taut.
opposite arm and leg lifted off the ground, extended and vulnerable.
back arched upward in smooth S-curve, hips lowered, head lifted.
Looking directly into camera. Powerful, alluring.
Perfect anatomy, realistic
---
# The Kneeling (Dynamic):
kneeling on all fours, hands under shoulders, knees under hips.
one arm and opposite leg lifted off the ground, creating diagonal tension.
hips lowered toward the ground, back forming a smooth curved arc.
muscular definition in lifted limbs, continuous curved lines of torso.
Looking directly into camera. Strong, dynamic.
Perfect anatomy, realistic
---
# The Sphinx:
lying on stomach, propped up on forearms.
elbows under shoulders, chest lifted, back arched deeply.
legs extended straight behind, toes pointed.
head lifted, neck elongated.
Looking directly into camera. Regal, poised.
Perfect anatomy, realistic
---
# The Offering:
kneeling upright, sitting back on heels.
arms extended straight overhead, hands open and reaching upward.
back arched slightly, chest open, shoulders down.
hips anchored heavy on heels, spine long and stretched.
Looking directly into camera. Devotional, open.
Perfect anatomy, realistic
---
# The Reclining Twist:
lying on side, propped up on one elbow.
top leg bent and raised, foot resting on ground in front of bottom leg.
bottom leg extended straight, toes pointed.
top arm resting on bent knee or reaching overhead.
torso twisted slightly toward camera, hips stacked.
Looking directly into camera. Relaxed, composed.
Perfect anatomy, realistic
---
# The Ascension:
lying on back.
arms extended overhead, hands clasped or reaching past the head.
legs lifted straight up together, fully extended toward ceiling.
lower back pressed flat to the ground, shoulders relaxed.
vertical line from shoulders through heels.
Looking directly into camera. Weightless, transcendent.
Perfect anatomy, realistic

View File

@@ -6,6 +6,11 @@ source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
source "$VENV/bin/activate"
cd "$API_DIR"
# Add all nvidia CUDA library paths bundled with the venv (needed by onnxruntime-gpu / insightface)
_NV_BASE="$VENV/lib/python3.13/site-packages/nvidia"
_NV_LIBPATH="$_NV_BASE/cuda_runtime/lib:$_NV_BASE/cublas/lib:$_NV_BASE/cudnn/lib:$_NV_BASE/curand/lib:$_NV_BASE/cufft/lib:$_NV_BASE/cusolver/lib:$_NV_BASE/cusparse/lib:$_NV_BASE/nvjitlink/lib:$_NV_BASE/cuda_nvrtc/lib"
export LD_LIBRARY_PATH="${_NV_LIBPATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export COMFY_URL="http://127.0.0.1:8188"
export HOST="0.0.0.0"
export PORT="8500"