Spaces:
Running
Running
Initial deployment - Content Engine
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- Dockerfile +40 -0
- README.md +41 -10
- config/characters/example_character.yaml +29 -0
- config/models.yaml +95 -0
- config/settings.yaml +47 -0
- config/templates/prompts/artistic_nude.yaml +100 -0
- config/templates/prompts/boudoir_intimate.yaml +112 -0
- config/templates/prompts/lifestyle_casual.yaml +88 -0
- config/templates/prompts/portrait_glamour.yaml +108 -0
- config/templates/workflows/sd15_base_nsfw.json +59 -0
- config/templates/workflows/sd15_base_sfw.json +59 -0
- config/templates/workflows/sd15_img2img_nsfw.json +64 -0
- config/templates/workflows/sd15_img2img_sfw.json +64 -0
- requirements.txt +15 -0
- src/content_engine.egg-info/PKG-INFO +25 -0
- src/content_engine.egg-info/SOURCES.txt +30 -0
- src/content_engine.egg-info/dependency_links.txt +1 -0
- src/content_engine.egg-info/requires.txt +22 -0
- src/content_engine.egg-info/top_level.txt +1 -0
- src/content_engine/__init__.py +3 -0
- src/content_engine/__pycache__/__init__.cpython-311.pyc +0 -0
- src/content_engine/__pycache__/config.cpython-311.pyc +0 -0
- src/content_engine/__pycache__/main.cpython-311.pyc +0 -0
- src/content_engine/api/__init__.py +1 -0
- src/content_engine/api/__pycache__/__init__.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_catalog.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_generation.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_pod.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_system.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_training.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_ui.cpython-311.pyc +0 -0
- src/content_engine/api/__pycache__/routes_video.cpython-311.pyc +0 -0
- src/content_engine/api/routes_catalog.py +169 -0
- src/content_engine/api/routes_generation.py +604 -0
- src/content_engine/api/routes_pod.py +545 -0
- src/content_engine/api/routes_system.py +235 -0
- src/content_engine/api/routes_training.py +269 -0
- src/content_engine/api/routes_ui.py +23 -0
- src/content_engine/api/routes_video.py +309 -0
- src/content_engine/api/ui.html +0 -0
- src/content_engine/config.py +93 -0
- src/content_engine/main.py +213 -0
- src/content_engine/models/__init__.py +31 -0
- src/content_engine/models/__pycache__/__init__.cpython-311.pyc +0 -0
- src/content_engine/models/__pycache__/database.cpython-311.pyc +0 -0
- src/content_engine/models/__pycache__/schemas.cpython-311.pyc +0 -0
- src/content_engine/models/database.py +166 -0
- src/content_engine/models/schemas.py +118 -0
- src/content_engine/services/__init__.py +1 -0
- src/content_engine/services/__pycache__/__init__.cpython-311.pyc +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Dockerfile for Content Engine
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Create user with UID 1000 (HF Spaces requirement)
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
|
| 7 |
+
# Install system dependencies as root
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
git \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Switch to user
|
| 13 |
+
USER user
|
| 14 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 15 |
+
|
| 16 |
+
WORKDIR /app
|
| 17 |
+
|
| 18 |
+
# Copy requirements first for caching
|
| 19 |
+
COPY --chown=user ./requirements.txt requirements.txt
|
| 20 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 21 |
+
|
| 22 |
+
# Copy application code
|
| 23 |
+
COPY --chown=user ./src ./src
|
| 24 |
+
COPY --chown=user ./config ./config
|
| 25 |
+
|
| 26 |
+
# Create directories for data persistence
|
| 27 |
+
RUN mkdir -p /app/data/output /app/data/output/videos /app/data/db /app/data/uploads /app/data/loras /app/data/models /app/data/training
|
| 28 |
+
|
| 29 |
+
# Set environment variables
|
| 30 |
+
ENV PYTHONUNBUFFERED=1
|
| 31 |
+
ENV PYTHONPATH=/app/src
|
| 32 |
+
ENV HF_SPACES=1
|
| 33 |
+
ENV OUTPUT_DIR=/app/data/output
|
| 34 |
+
ENV DATA_DIR=/app/data
|
| 35 |
+
ENV DB_PATH=/app/data/db/content_engine.db
|
| 36 |
+
ENV UPLOAD_DIR=/app/data/uploads
|
| 37 |
+
|
| 38 |
+
# HF Spaces requires port 7860
|
| 39 |
+
EXPOSE 7860
|
| 40 |
+
CMD ["uvicorn", "content_engine.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,41 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Content Engine - AI Image & Video Generation
|
| 2 |
+
|
| 3 |
+
A web-based content generation platform using RunPod for GPU-powered image and video generation.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **Image Generation**: FLUX.2 and WAN 2.2 models via RunPod GPU
|
| 8 |
+
- **Video Generation**: WAN 2.2 Image-to-Video
|
| 9 |
+
- **LoRA Training**: Train custom character models
|
| 10 |
+
- **Gallery**: Browse, download, and manage generated content
|
| 11 |
+
- **Templates**: Pre-configured prompts for consistent results
|
| 12 |
+
|
| 13 |
+
## Setup
|
| 14 |
+
|
| 15 |
+
This Space requires RunPod API credentials to function:
|
| 16 |
+
|
| 17 |
+
1. Get your RunPod API key from https://www.runpod.io/console/user/settings
|
| 18 |
+
2. Add it as a Space Secret: `RUNPOD_API_KEY`
|
| 19 |
+
|
| 20 |
+
Optional:
|
| 21 |
+
- `WAVESPEED_API_KEY`: For WaveSpeed cloud generation (alternative backend)
|
| 22 |
+
|
| 23 |
+
## Usage
|
| 24 |
+
|
| 25 |
+
1. Go to **Status** page and click **Start Pod** to boot a GPU
|
| 26 |
+
2. Wait ~2-3 minutes for the pod to be ready
|
| 27 |
+
3. Use **Generate** page to create images/videos
|
| 28 |
+
4. **Stop Pod** when done to save costs
|
| 29 |
+
|
| 30 |
+
## Cost
|
| 31 |
+
|
| 32 |
+
- RunPod GPU (RTX 4090): ~$0.44/hour while running
|
| 33 |
+
- No cost when pod is stopped
|
| 34 |
+
- Images/videos stored on Hugging Face (free)
|
| 35 |
+
|
| 36 |
+
## Tech Stack
|
| 37 |
+
|
| 38 |
+
- FastAPI backend
|
| 39 |
+
- RunPod for GPU compute
|
| 40 |
+
- SQLite for metadata
|
| 41 |
+
- Pure HTML/CSS/JS frontend
|
config/characters/example_character.yaml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Example character profile — replace with your trained LoRA details
|
| 2 |
+
#
|
| 3 |
+
# To use this:
|
| 4 |
+
# 1. Train a LoRA using Kohya_ss with ~20-50 reference images
|
| 5 |
+
# 2. Place the .safetensors file in D:\ComfyUI\Models\Lora\
|
| 6 |
+
# 3. Update the fields below with your character's details
|
| 7 |
+
# 4. Rename this file to your character's name (e.g., alice.yaml)
|
| 8 |
+
|
| 9 |
+
id: example
|
| 10 |
+
name: "Example Character"
|
| 11 |
+
trigger_word: "examplechar" # The trigger word used during LoRA training
|
| 12 |
+
lora_filename: "example_v1.safetensors" # Filename in D:\ComfyUI\Models\Lora\
|
| 13 |
+
lora_strength: 0.85 # 0.6-0.9 typically works best
|
| 14 |
+
|
| 15 |
+
# Optional: override default checkpoint for this character
|
| 16 |
+
# default_checkpoint: "realisticVisionV51_v51VAE.safetensors"
|
| 17 |
+
|
| 18 |
+
# Optional: additional style LoRAs to stack
|
| 19 |
+
style_loras: []
|
| 20 |
+
# - name: "glamour_style_v1.safetensors"
|
| 21 |
+
# strength_model: 0.5
|
| 22 |
+
# strength_clip: 0.5
|
| 23 |
+
|
| 24 |
+
description: "Example character for testing the pipeline"
|
| 25 |
+
|
| 26 |
+
physical_traits:
|
| 27 |
+
hair: "brown, shoulder length"
|
| 28 |
+
eyes: "blue"
|
| 29 |
+
build: "average"
|
config/models.yaml
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Training Model Registry
|
| 2 |
+
# Defines base models available for LoRA training with their optimal parameters
|
| 3 |
+
|
| 4 |
+
training_models:
|
| 5 |
+
# FLUX - Best for photorealistic images (recommended for realistic person)
|
| 6 |
+
flux2_dev:
|
| 7 |
+
name: "FLUX.2 Dev (Recommended)"
|
| 8 |
+
description: "Latest FLUX model, 32B params, best quality for realistic person. Also supports multi-reference without training."
|
| 9 |
+
hf_repo: "black-forest-labs/FLUX.2-dev"
|
| 10 |
+
hf_filename: "flux.2-dev.safetensors"
|
| 11 |
+
model_type: "flux"
|
| 12 |
+
resolution: 1024
|
| 13 |
+
learning_rate: 1e-3
|
| 14 |
+
text_encoder_lr: 1e-4
|
| 15 |
+
network_rank: 48
|
| 16 |
+
network_alpha: 24
|
| 17 |
+
clip_skip: 1
|
| 18 |
+
optimizer: "AdamW8bit"
|
| 19 |
+
lr_scheduler: "cosine"
|
| 20 |
+
min_snr_gamma: 5
|
| 21 |
+
max_train_steps: 1200
|
| 22 |
+
fp8_base: true
|
| 23 |
+
use_case: "images"
|
| 24 |
+
vram_required_gb: 24
|
| 25 |
+
recommended_images: "15-30 high quality photos with detailed captions"
|
| 26 |
+
training_script: "flux_train_network.py"
|
| 27 |
+
|
| 28 |
+
flux1_dev:
|
| 29 |
+
name: "FLUX.1 Dev"
|
| 30 |
+
description: "Previous gen FLUX, still excellent for realistic person LoRAs"
|
| 31 |
+
hf_repo: "black-forest-labs/FLUX.1-dev"
|
| 32 |
+
hf_filename: "flux1-dev.safetensors"
|
| 33 |
+
model_type: "flux"
|
| 34 |
+
resolution: 768
|
| 35 |
+
learning_rate: 4e-4
|
| 36 |
+
text_encoder_lr: 4e-5
|
| 37 |
+
network_rank: 32
|
| 38 |
+
network_alpha: 16
|
| 39 |
+
clip_skip: 1
|
| 40 |
+
optimizer: "AdamW8bit"
|
| 41 |
+
lr_scheduler: "cosine"
|
| 42 |
+
min_snr_gamma: 5
|
| 43 |
+
max_train_steps: 1500
|
| 44 |
+
use_case: "images"
|
| 45 |
+
vram_required_gb: 24
|
| 46 |
+
recommended_images: "15-30 high quality photos"
|
| 47 |
+
training_script: "flux_train_network.py"
|
| 48 |
+
|
| 49 |
+
# SD 1.5 Realistic Vision - Good balance of quality and speed
|
| 50 |
+
sd15_realistic:
|
| 51 |
+
name: "Realistic Vision V5.1"
|
| 52 |
+
description: "SD 1.5 based, great for realistic humans, faster training"
|
| 53 |
+
hf_repo: "SG161222/Realistic_Vision_V5.1_noVAE"
|
| 54 |
+
hf_filename: "Realistic_Vision_V5.1_fp16-no-ema.safetensors"
|
| 55 |
+
model_type: "sd15"
|
| 56 |
+
resolution: 512
|
| 57 |
+
learning_rate: 1e-4
|
| 58 |
+
network_rank: 32
|
| 59 |
+
network_alpha: 16
|
| 60 |
+
clip_skip: 1
|
| 61 |
+
optimizer: "AdamW8bit"
|
| 62 |
+
use_case: "images"
|
| 63 |
+
vram_required_gb: 8
|
| 64 |
+
recommended_images: "15-30 photos"
|
| 65 |
+
|
| 66 |
+
# SDXL - Higher quality than SD 1.5, but more VRAM
|
| 67 |
+
sdxl_base:
|
| 68 |
+
name: "SDXL Base 1.0"
|
| 69 |
+
description: "Higher resolution and quality than SD 1.5"
|
| 70 |
+
hf_repo: "stabilityai/stable-diffusion-xl-base-1.0"
|
| 71 |
+
hf_filename: "sd_xl_base_1.0.safetensors"
|
| 72 |
+
model_type: "sdxl"
|
| 73 |
+
resolution: 1024
|
| 74 |
+
learning_rate: 1e-4
|
| 75 |
+
network_rank: 32
|
| 76 |
+
network_alpha: 16
|
| 77 |
+
clip_skip: 2
|
| 78 |
+
optimizer: "AdamW8bit"
|
| 79 |
+
use_case: "images"
|
| 80 |
+
vram_required_gb: 12
|
| 81 |
+
recommended_images: "20-40 photos"
|
| 82 |
+
|
| 83 |
+
# Video generation models (for img2video, not training)
|
| 84 |
+
video_models:
|
| 85 |
+
wan22_i2v:
|
| 86 |
+
name: "WAN 2.2 Image-to-Video"
|
| 87 |
+
description: "Converts images to videos, use with your trained LoRA images"
|
| 88 |
+
hf_repo: "Wan-AI/Wan2.2-I2V-A14B"
|
| 89 |
+
model_type: "wan22"
|
| 90 |
+
use_case: "img2video"
|
| 91 |
+
vram_required_gb: 24
|
| 92 |
+
resolution: "480p/720p"
|
| 93 |
+
|
| 94 |
+
# Default model for training
|
| 95 |
+
default_training_model: "flux2_dev"
|
config/settings.yaml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Content Engine Configuration
|
| 2 |
+
|
| 3 |
+
comfyui:
|
| 4 |
+
url: "http://127.0.0.1:8188"
|
| 5 |
+
# Maximum jobs to queue locally before routing to cloud
|
| 6 |
+
max_local_queue_depth: 3
|
| 7 |
+
# Minimum free VRAM (GB) required to accept a local job
|
| 8 |
+
min_vram_gb: 2.0
|
| 9 |
+
|
| 10 |
+
paths:
|
| 11 |
+
output_dir: "D:/AI automation/output"
|
| 12 |
+
data_dir: "D:/AI automation/data"
|
| 13 |
+
# ComfyUI model paths (from extra_model_paths.yaml)
|
| 14 |
+
lora_dir: "D:/ComfyUI/Models/Lora"
|
| 15 |
+
checkpoint_dir: "D:/ComfyUI/Models/StableDiffusion"
|
| 16 |
+
|
| 17 |
+
database:
|
| 18 |
+
# SQLite for v1, switch to postgresql:// for v2
|
| 19 |
+
url: "sqlite+aiosqlite:///D:/AI automation/data/catalog.db"
|
| 20 |
+
jobs_url: "sqlite+aiosqlite:///D:/AI automation/data/jobs.db"
|
| 21 |
+
|
| 22 |
+
generation:
|
| 23 |
+
# Default generation parameters
|
| 24 |
+
default_checkpoint: "realisticVisionV51_v51VAE.safetensors"
|
| 25 |
+
default_steps: 28
|
| 26 |
+
default_cfg: 7.0
|
| 27 |
+
default_sampler: "dpmpp_2m"
|
| 28 |
+
default_scheduler: "karras"
|
| 29 |
+
default_width: 832
|
| 30 |
+
default_height: 1216
|
| 31 |
+
|
| 32 |
+
scheduling:
|
| 33 |
+
# Posts per day per character
|
| 34 |
+
posts_per_day: 3
|
| 35 |
+
# Peak posting hours (UTC)
|
| 36 |
+
peak_hours: [10, 14, 20]
|
| 37 |
+
# SFW to NSFW ratio for scheduling
|
| 38 |
+
sfw_ratio: 0.4
|
| 39 |
+
|
| 40 |
+
cloud_providers: []
|
| 41 |
+
# Uncomment and configure when ready (Phase 4)
|
| 42 |
+
# - name: replicate
|
| 43 |
+
# api_key: "${REPLICATE_API_KEY}"
|
| 44 |
+
# priority: 1
|
| 45 |
+
# - name: runpod
|
| 46 |
+
# api_key: "${RUNPOD_API_KEY}"
|
| 47 |
+
# priority: 2
|
config/templates/prompts/artistic_nude.yaml
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: artistic_nude
|
| 2 |
+
name: "Artistic Nude"
|
| 3 |
+
category: artistic
|
| 4 |
+
rating: nsfw
|
| 5 |
+
base_model: realistic_vision
|
| 6 |
+
|
| 7 |
+
loras:
|
| 8 |
+
- name: "{{character_lora}}"
|
| 9 |
+
strength_model: 0.85
|
| 10 |
+
strength_clip: 0.85
|
| 11 |
+
|
| 12 |
+
positive_prompt: >
|
| 13 |
+
{{character_trigger}}, {{pose}}, nude,
|
| 14 |
+
{{emotion}} expression, {{camera_angle}},
|
| 15 |
+
{{lighting}}, {{scene}},
|
| 16 |
+
masterpiece, best quality, photorealistic, 8k uhd,
|
| 17 |
+
detailed skin texture, fine art photography,
|
| 18 |
+
artistic composition, tasteful, elegant
|
| 19 |
+
|
| 20 |
+
negative_prompt: >
|
| 21 |
+
worst quality, low quality, blurry, deformed,
|
| 22 |
+
bad anatomy, bad hands, extra fingers,
|
| 23 |
+
watermark, text, signature, cartoon, anime,
|
| 24 |
+
unrealistic proportions, ugly
|
| 25 |
+
|
| 26 |
+
sampler:
|
| 27 |
+
steps: 30
|
| 28 |
+
cfg: 7.5
|
| 29 |
+
sampler_name: dpmpp_2m
|
| 30 |
+
scheduler: karras
|
| 31 |
+
width: 832
|
| 32 |
+
height: 1216
|
| 33 |
+
|
| 34 |
+
variables:
|
| 35 |
+
character_trigger:
|
| 36 |
+
type: string
|
| 37 |
+
required: true
|
| 38 |
+
description: "Character trigger word from LoRA training"
|
| 39 |
+
character_lora:
|
| 40 |
+
type: string
|
| 41 |
+
required: true
|
| 42 |
+
description: "Character LoRA filename"
|
| 43 |
+
pose:
|
| 44 |
+
type: choice
|
| 45 |
+
options:
|
| 46 |
+
- reclining
|
| 47 |
+
- standing profile
|
| 48 |
+
- seated with crossed legs
|
| 49 |
+
- back view
|
| 50 |
+
- curled up
|
| 51 |
+
- stretching
|
| 52 |
+
- lying on stomach
|
| 53 |
+
- kneeling
|
| 54 |
+
emotion:
|
| 55 |
+
type: choice
|
| 56 |
+
options:
|
| 57 |
+
- serene
|
| 58 |
+
- contemplative
|
| 59 |
+
- confident
|
| 60 |
+
- vulnerable
|
| 61 |
+
- sensual
|
| 62 |
+
- mysterious
|
| 63 |
+
- peaceful
|
| 64 |
+
camera_angle:
|
| 65 |
+
type: choice
|
| 66 |
+
options:
|
| 67 |
+
- eye level
|
| 68 |
+
- above looking down
|
| 69 |
+
- low angle
|
| 70 |
+
- side profile
|
| 71 |
+
- three quarter
|
| 72 |
+
- from behind
|
| 73 |
+
lighting:
|
| 74 |
+
type: choice
|
| 75 |
+
options:
|
| 76 |
+
- chiaroscuro
|
| 77 |
+
- soft diffused
|
| 78 |
+
- rim lighting
|
| 79 |
+
- golden hour
|
| 80 |
+
- dramatic single source
|
| 81 |
+
- natural daylight
|
| 82 |
+
- moody low key
|
| 83 |
+
scene:
|
| 84 |
+
type: choice
|
| 85 |
+
options:
|
| 86 |
+
- minimalist studio
|
| 87 |
+
- natural landscape
|
| 88 |
+
- classical interior
|
| 89 |
+
- fabric drapes
|
| 90 |
+
- water reflection
|
| 91 |
+
- garden
|
| 92 |
+
- abstract background
|
| 93 |
+
|
| 94 |
+
motion:
|
| 95 |
+
enabled: false
|
| 96 |
+
type: loop
|
| 97 |
+
intensity: 0.3
|
| 98 |
+
motion_keywords:
|
| 99 |
+
- "slow breathing"
|
| 100 |
+
- "gentle wind"
|
config/templates/prompts/boudoir_intimate.yaml
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: boudoir_intimate
|
| 2 |
+
name: "Boudoir Intimate"
|
| 3 |
+
category: boudoir
|
| 4 |
+
rating: nsfw
|
| 5 |
+
base_model: realistic_vision
|
| 6 |
+
|
| 7 |
+
loras:
|
| 8 |
+
- name: "{{character_lora}}"
|
| 9 |
+
strength_model: 0.85
|
| 10 |
+
strength_clip: 0.85
|
| 11 |
+
|
| 12 |
+
positive_prompt: >
|
| 13 |
+
{{character_trigger}}, {{pose}}, {{outfit}},
|
| 14 |
+
{{emotion}} expression, {{camera_angle}},
|
| 15 |
+
{{lighting}}, {{scene}},
|
| 16 |
+
masterpiece, best quality, photorealistic, 8k uhd,
|
| 17 |
+
detailed skin texture, professional boudoir photography,
|
| 18 |
+
intimate atmosphere, sensual, alluring
|
| 19 |
+
|
| 20 |
+
negative_prompt: >
|
| 21 |
+
worst quality, low quality, blurry, deformed,
|
| 22 |
+
bad anatomy, bad hands, extra fingers,
|
| 23 |
+
watermark, text, signature, cartoon, anime,
|
| 24 |
+
unrealistic proportions
|
| 25 |
+
|
| 26 |
+
sampler:
|
| 27 |
+
steps: 30
|
| 28 |
+
cfg: 7.0
|
| 29 |
+
sampler_name: dpmpp_2m
|
| 30 |
+
scheduler: karras
|
| 31 |
+
width: 832
|
| 32 |
+
height: 1216
|
| 33 |
+
|
| 34 |
+
variables:
|
| 35 |
+
character_trigger:
|
| 36 |
+
type: string
|
| 37 |
+
required: true
|
| 38 |
+
description: "Character trigger word from LoRA training"
|
| 39 |
+
character_lora:
|
| 40 |
+
type: string
|
| 41 |
+
required: true
|
| 42 |
+
description: "Character LoRA filename"
|
| 43 |
+
pose:
|
| 44 |
+
type: choice
|
| 45 |
+
options:
|
| 46 |
+
- lying on bed
|
| 47 |
+
- sitting on edge of bed
|
| 48 |
+
- standing by window
|
| 49 |
+
- kneeling
|
| 50 |
+
- reclining on couch
|
| 51 |
+
- looking over shoulder
|
| 52 |
+
- stretching
|
| 53 |
+
- leaning forward
|
| 54 |
+
outfit:
|
| 55 |
+
type: choice
|
| 56 |
+
options:
|
| 57 |
+
- lingerie
|
| 58 |
+
- silk robe
|
| 59 |
+
- lace bodysuit
|
| 60 |
+
- sheer nightgown
|
| 61 |
+
- corset
|
| 62 |
+
- bikini
|
| 63 |
+
- oversized shirt
|
| 64 |
+
- towel
|
| 65 |
+
emotion:
|
| 66 |
+
type: choice
|
| 67 |
+
options:
|
| 68 |
+
- seductive
|
| 69 |
+
- playful
|
| 70 |
+
- confident
|
| 71 |
+
- dreamy
|
| 72 |
+
- mysterious
|
| 73 |
+
- inviting
|
| 74 |
+
- coy
|
| 75 |
+
camera_angle:
|
| 76 |
+
type: choice
|
| 77 |
+
options:
|
| 78 |
+
- eye level
|
| 79 |
+
- low angle
|
| 80 |
+
- high angle looking down
|
| 81 |
+
- close-up
|
| 82 |
+
- three quarter view
|
| 83 |
+
- from behind
|
| 84 |
+
lighting:
|
| 85 |
+
type: choice
|
| 86 |
+
options:
|
| 87 |
+
- warm candlelight
|
| 88 |
+
- soft window light
|
| 89 |
+
- golden hour
|
| 90 |
+
- dim ambient light
|
| 91 |
+
- neon accent lighting
|
| 92 |
+
- dramatic shadows
|
| 93 |
+
- backlit silhouette
|
| 94 |
+
scene:
|
| 95 |
+
type: choice
|
| 96 |
+
options:
|
| 97 |
+
- luxury bedroom
|
| 98 |
+
- hotel room
|
| 99 |
+
- bathtub
|
| 100 |
+
- balcony at dusk
|
| 101 |
+
- silk sheets
|
| 102 |
+
- vanity mirror
|
| 103 |
+
- penthouse suite
|
| 104 |
+
|
| 105 |
+
motion:
|
| 106 |
+
enabled: false
|
| 107 |
+
type: loop
|
| 108 |
+
intensity: 0.5
|
| 109 |
+
motion_keywords:
|
| 110 |
+
- "slow breathing"
|
| 111 |
+
- "gentle movement"
|
| 112 |
+
- "hair falling"
|
config/templates/prompts/lifestyle_casual.yaml
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: lifestyle_casual
|
| 2 |
+
name: "Casual Lifestyle"
|
| 3 |
+
category: lifestyle
|
| 4 |
+
rating: sfw
|
| 5 |
+
base_model: realistic_vision
|
| 6 |
+
|
| 7 |
+
loras:
|
| 8 |
+
- name: "{{character_lora}}"
|
| 9 |
+
strength_model: 0.85
|
| 10 |
+
strength_clip: 0.85
|
| 11 |
+
|
| 12 |
+
positive_prompt: >
|
| 13 |
+
{{character_trigger}}, casual lifestyle photo,
|
| 14 |
+
{{activity}}, {{outfit}}, {{emotion}} expression,
|
| 15 |
+
{{camera_angle}}, {{lighting}}, {{scene}},
|
| 16 |
+
masterpiece, best quality, photorealistic,
|
| 17 |
+
candid photography style, natural look
|
| 18 |
+
|
| 19 |
+
negative_prompt: >
|
| 20 |
+
worst quality, low quality, blurry, deformed,
|
| 21 |
+
bad anatomy, bad hands, extra fingers,
|
| 22 |
+
watermark, text, signature, overly posed
|
| 23 |
+
|
| 24 |
+
sampler:
|
| 25 |
+
steps: 25
|
| 26 |
+
cfg: 6.5
|
| 27 |
+
sampler_name: dpmpp_2m
|
| 28 |
+
scheduler: karras
|
| 29 |
+
width: 1024
|
| 30 |
+
height: 1024
|
| 31 |
+
|
| 32 |
+
variables:
|
| 33 |
+
character_trigger:
|
| 34 |
+
type: string
|
| 35 |
+
required: true
|
| 36 |
+
character_lora:
|
| 37 |
+
type: string
|
| 38 |
+
required: true
|
| 39 |
+
activity:
|
| 40 |
+
type: choice
|
| 41 |
+
options:
|
| 42 |
+
- reading a book
|
| 43 |
+
- drinking coffee
|
| 44 |
+
- walking in park
|
| 45 |
+
- stretching
|
| 46 |
+
- cooking
|
| 47 |
+
- using laptop
|
| 48 |
+
- taking selfie
|
| 49 |
+
outfit:
|
| 50 |
+
type: choice
|
| 51 |
+
options:
|
| 52 |
+
- oversized sweater
|
| 53 |
+
- yoga pants and tank top
|
| 54 |
+
- summer dress
|
| 55 |
+
- jeans and t-shirt
|
| 56 |
+
- pajamas
|
| 57 |
+
- workout clothes
|
| 58 |
+
emotion:
|
| 59 |
+
type: choice
|
| 60 |
+
options:
|
| 61 |
+
- relaxed
|
| 62 |
+
- happy
|
| 63 |
+
- focused
|
| 64 |
+
- dreamy
|
| 65 |
+
- cheerful
|
| 66 |
+
camera_angle:
|
| 67 |
+
type: choice
|
| 68 |
+
options:
|
| 69 |
+
- eye level
|
| 70 |
+
- slightly above
|
| 71 |
+
- candid angle
|
| 72 |
+
- over the shoulder
|
| 73 |
+
lighting:
|
| 74 |
+
type: choice
|
| 75 |
+
options:
|
| 76 |
+
- morning light
|
| 77 |
+
- afternoon sun
|
| 78 |
+
- warm indoor lighting
|
| 79 |
+
- window light
|
| 80 |
+
scene:
|
| 81 |
+
type: choice
|
| 82 |
+
options:
|
| 83 |
+
- cozy bedroom
|
| 84 |
+
- modern kitchen
|
| 85 |
+
- sunny balcony
|
| 86 |
+
- coffee shop
|
| 87 |
+
- living room with plants
|
| 88 |
+
- yoga studio
|
config/templates/prompts/portrait_glamour.yaml
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
id: portrait_glamour
|
| 2 |
+
name: "Glamour Portrait"
|
| 3 |
+
category: portrait
|
| 4 |
+
rating: sfw
|
| 5 |
+
base_model: realistic_vision
|
| 6 |
+
|
| 7 |
+
loras:
|
| 8 |
+
- name: "{{character_lora}}"
|
| 9 |
+
strength_model: 0.85
|
| 10 |
+
strength_clip: 0.85
|
| 11 |
+
|
| 12 |
+
positive_prompt: >
|
| 13 |
+
{{character_trigger}}, {{pose}}, {{outfit}},
|
| 14 |
+
{{emotion}} expression, {{camera_angle}},
|
| 15 |
+
{{lighting}}, {{scene}},
|
| 16 |
+
masterpiece, best quality, photorealistic, 8k uhd,
|
| 17 |
+
detailed skin texture, professional photography
|
| 18 |
+
|
| 19 |
+
negative_prompt: >
|
| 20 |
+
worst quality, low quality, blurry, deformed,
|
| 21 |
+
bad anatomy, bad hands, extra fingers,
|
| 22 |
+
watermark, text, signature
|
| 23 |
+
|
| 24 |
+
sampler:
|
| 25 |
+
steps: 28
|
| 26 |
+
cfg: 7.0
|
| 27 |
+
sampler_name: dpmpp_2m
|
| 28 |
+
scheduler: karras
|
| 29 |
+
width: 832
|
| 30 |
+
height: 1216
|
| 31 |
+
|
| 32 |
+
variables:
|
| 33 |
+
character_trigger:
|
| 34 |
+
type: string
|
| 35 |
+
required: true
|
| 36 |
+
description: "Character trigger word from LoRA training"
|
| 37 |
+
character_lora:
|
| 38 |
+
type: string
|
| 39 |
+
required: true
|
| 40 |
+
description: "Character LoRA filename"
|
| 41 |
+
pose:
|
| 42 |
+
type: choice
|
| 43 |
+
options:
|
| 44 |
+
- standing
|
| 45 |
+
- sitting
|
| 46 |
+
- leaning against wall
|
| 47 |
+
- walking
|
| 48 |
+
- looking over shoulder
|
| 49 |
+
- hands on hips
|
| 50 |
+
- arms crossed
|
| 51 |
+
outfit:
|
| 52 |
+
type: choice
|
| 53 |
+
options:
|
| 54 |
+
- casual dress
|
| 55 |
+
- evening gown
|
| 56 |
+
- business suit
|
| 57 |
+
- athletic wear
|
| 58 |
+
- sundress
|
| 59 |
+
- leather jacket
|
| 60 |
+
- crop top and jeans
|
| 61 |
+
emotion:
|
| 62 |
+
type: choice
|
| 63 |
+
options:
|
| 64 |
+
- confident
|
| 65 |
+
- playful
|
| 66 |
+
- serious
|
| 67 |
+
- mysterious
|
| 68 |
+
- warm smile
|
| 69 |
+
- contemplative
|
| 70 |
+
- laughing
|
| 71 |
+
camera_angle:
|
| 72 |
+
type: choice
|
| 73 |
+
options:
|
| 74 |
+
- front view
|
| 75 |
+
- three quarter view
|
| 76 |
+
- side profile
|
| 77 |
+
- low angle
|
| 78 |
+
- high angle
|
| 79 |
+
- close-up portrait
|
| 80 |
+
lighting:
|
| 81 |
+
type: choice
|
| 82 |
+
options:
|
| 83 |
+
- natural light
|
| 84 |
+
- golden hour
|
| 85 |
+
- studio lighting
|
| 86 |
+
- rim lighting
|
| 87 |
+
- neon lighting
|
| 88 |
+
- dramatic shadows
|
| 89 |
+
- soft diffused light
|
| 90 |
+
scene:
|
| 91 |
+
type: choice
|
| 92 |
+
options:
|
| 93 |
+
- urban rooftop
|
| 94 |
+
- luxury interior
|
| 95 |
+
- garden
|
| 96 |
+
- beach at sunset
|
| 97 |
+
- studio backdrop
|
| 98 |
+
- cozy cafe
|
| 99 |
+
- city street at night
|
| 100 |
+
|
| 101 |
+
motion:
|
| 102 |
+
enabled: false
|
| 103 |
+
type: loop
|
| 104 |
+
intensity: 0.7
|
| 105 |
+
motion_keywords:
|
| 106 |
+
- "gentle swaying"
|
| 107 |
+
- "hair flowing in wind"
|
| 108 |
+
- "slow breathing"
|
config/templates/workflows/sd15_base_nsfw.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"1": {
|
| 3 |
+
"class_type": "CheckpointLoaderSimple",
|
| 4 |
+
"inputs": {
|
| 5 |
+
"ckpt_name": "realisticVisionV51_v51VAE.safetensors"
|
| 6 |
+
}
|
| 7 |
+
},
|
| 8 |
+
"2": {
|
| 9 |
+
"class_type": "CLIPTextEncode",
|
| 10 |
+
"inputs": {
|
| 11 |
+
"clip": ["1", 1],
|
| 12 |
+
"text": ""
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"3": {
|
| 16 |
+
"class_type": "CLIPTextEncode",
|
| 17 |
+
"inputs": {
|
| 18 |
+
"clip": ["1", 1],
|
| 19 |
+
"text": "worst quality, low quality, blurry, deformed, bad anatomy, bad hands, extra fingers, watermark, text, signature, censored"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"4": {
|
| 23 |
+
"class_type": "EmptyLatentImage",
|
| 24 |
+
"inputs": {
|
| 25 |
+
"width": 832,
|
| 26 |
+
"height": 1216,
|
| 27 |
+
"batch_size": 1
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"5": {
|
| 31 |
+
"class_type": "KSampler",
|
| 32 |
+
"inputs": {
|
| 33 |
+
"model": ["1", 0],
|
| 34 |
+
"positive": ["2", 0],
|
| 35 |
+
"negative": ["3", 0],
|
| 36 |
+
"latent_image": ["4", 0],
|
| 37 |
+
"seed": 0,
|
| 38 |
+
"steps": 28,
|
| 39 |
+
"cfg": 7.0,
|
| 40 |
+
"sampler_name": "dpmpp_2m",
|
| 41 |
+
"scheduler": "karras",
|
| 42 |
+
"denoise": 1.0
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
"6": {
|
| 46 |
+
"class_type": "VAEDecode",
|
| 47 |
+
"inputs": {
|
| 48 |
+
"samples": ["5", 0],
|
| 49 |
+
"vae": ["1", 2]
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
"7": {
|
| 53 |
+
"class_type": "SaveImage",
|
| 54 |
+
"inputs": {
|
| 55 |
+
"images": ["6", 0],
|
| 56 |
+
"filename_prefix": "content_engine"
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
config/templates/workflows/sd15_base_sfw.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"1": {
|
| 3 |
+
"class_type": "CheckpointLoaderSimple",
|
| 4 |
+
"inputs": {
|
| 5 |
+
"ckpt_name": "realisticVisionV51_v51VAE.safetensors"
|
| 6 |
+
}
|
| 7 |
+
},
|
| 8 |
+
"2": {
|
| 9 |
+
"class_type": "CLIPTextEncode",
|
| 10 |
+
"inputs": {
|
| 11 |
+
"clip": ["1", 1],
|
| 12 |
+
"text": ""
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"3": {
|
| 16 |
+
"class_type": "CLIPTextEncode",
|
| 17 |
+
"inputs": {
|
| 18 |
+
"clip": ["1", 1],
|
| 19 |
+
"text": "worst quality, low quality, blurry, deformed, bad anatomy, bad hands, extra fingers, watermark, text, signature"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"4": {
|
| 23 |
+
"class_type": "EmptyLatentImage",
|
| 24 |
+
"inputs": {
|
| 25 |
+
"width": 832,
|
| 26 |
+
"height": 1216,
|
| 27 |
+
"batch_size": 1
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
"5": {
|
| 31 |
+
"class_type": "KSampler",
|
| 32 |
+
"inputs": {
|
| 33 |
+
"model": ["1", 0],
|
| 34 |
+
"positive": ["2", 0],
|
| 35 |
+
"negative": ["3", 0],
|
| 36 |
+
"latent_image": ["4", 0],
|
| 37 |
+
"seed": 0,
|
| 38 |
+
"steps": 28,
|
| 39 |
+
"cfg": 7.0,
|
| 40 |
+
"sampler_name": "dpmpp_2m",
|
| 41 |
+
"scheduler": "karras",
|
| 42 |
+
"denoise": 1.0
|
| 43 |
+
}
|
| 44 |
+
},
|
| 45 |
+
"6": {
|
| 46 |
+
"class_type": "VAEDecode",
|
| 47 |
+
"inputs": {
|
| 48 |
+
"samples": ["5", 0],
|
| 49 |
+
"vae": ["1", 2]
|
| 50 |
+
}
|
| 51 |
+
},
|
| 52 |
+
"7": {
|
| 53 |
+
"class_type": "SaveImage",
|
| 54 |
+
"inputs": {
|
| 55 |
+
"images": ["6", 0],
|
| 56 |
+
"filename_prefix": "content_engine"
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
config/templates/workflows/sd15_img2img_nsfw.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"1": {
|
| 3 |
+
"class_type": "CheckpointLoaderSimple",
|
| 4 |
+
"inputs": {
|
| 5 |
+
"ckpt_name": "realisticVisionV51_v51VAE.safetensors"
|
| 6 |
+
}
|
| 7 |
+
},
|
| 8 |
+
"2": {
|
| 9 |
+
"class_type": "CLIPTextEncode",
|
| 10 |
+
"inputs": {
|
| 11 |
+
"clip": ["1", 1],
|
| 12 |
+
"text": ""
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"3": {
|
| 16 |
+
"class_type": "CLIPTextEncode",
|
| 17 |
+
"inputs": {
|
| 18 |
+
"clip": ["1", 1],
|
| 19 |
+
"text": "worst quality, low quality, blurry, deformed, bad anatomy, bad hands, extra fingers, watermark, text, signature"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"8": {
|
| 23 |
+
"class_type": "LoadImage",
|
| 24 |
+
"inputs": {
|
| 25 |
+
"image": "input_image.png"
|
| 26 |
+
}
|
| 27 |
+
},
|
| 28 |
+
"9": {
|
| 29 |
+
"class_type": "VAEEncode",
|
| 30 |
+
"inputs": {
|
| 31 |
+
"pixels": ["8", 0],
|
| 32 |
+
"vae": ["1", 2]
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
"5": {
|
| 36 |
+
"class_type": "KSampler",
|
| 37 |
+
"inputs": {
|
| 38 |
+
"model": ["1", 0],
|
| 39 |
+
"positive": ["2", 0],
|
| 40 |
+
"negative": ["3", 0],
|
| 41 |
+
"latent_image": ["9", 0],
|
| 42 |
+
"seed": 0,
|
| 43 |
+
"steps": 28,
|
| 44 |
+
"cfg": 7.0,
|
| 45 |
+
"sampler_name": "dpmpp_2m",
|
| 46 |
+
"scheduler": "karras",
|
| 47 |
+
"denoise": 0.65
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"6": {
|
| 51 |
+
"class_type": "VAEDecode",
|
| 52 |
+
"inputs": {
|
| 53 |
+
"samples": ["5", 0],
|
| 54 |
+
"vae": ["1", 2]
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"7": {
|
| 58 |
+
"class_type": "SaveImage",
|
| 59 |
+
"inputs": {
|
| 60 |
+
"images": ["6", 0],
|
| 61 |
+
"filename_prefix": "content_engine_img2img"
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
config/templates/workflows/sd15_img2img_sfw.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"1": {
|
| 3 |
+
"class_type": "CheckpointLoaderSimple",
|
| 4 |
+
"inputs": {
|
| 5 |
+
"ckpt_name": "realisticVisionV51_v51VAE.safetensors"
|
| 6 |
+
}
|
| 7 |
+
},
|
| 8 |
+
"2": {
|
| 9 |
+
"class_type": "CLIPTextEncode",
|
| 10 |
+
"inputs": {
|
| 11 |
+
"clip": ["1", 1],
|
| 12 |
+
"text": ""
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
"3": {
|
| 16 |
+
"class_type": "CLIPTextEncode",
|
| 17 |
+
"inputs": {
|
| 18 |
+
"clip": ["1", 1],
|
| 19 |
+
"text": "worst quality, low quality, blurry, deformed, bad anatomy, bad hands, extra fingers, watermark, text, signature"
|
| 20 |
+
}
|
| 21 |
+
},
|
| 22 |
+
"8": {
|
| 23 |
+
"class_type": "LoadImage",
|
| 24 |
+
"inputs": {
|
| 25 |
+
"image": "input_image.png"
|
| 26 |
+
}
|
| 27 |
+
},
|
| 28 |
+
"9": {
|
| 29 |
+
"class_type": "VAEEncode",
|
| 30 |
+
"inputs": {
|
| 31 |
+
"pixels": ["8", 0],
|
| 32 |
+
"vae": ["1", 2]
|
| 33 |
+
}
|
| 34 |
+
},
|
| 35 |
+
"5": {
|
| 36 |
+
"class_type": "KSampler",
|
| 37 |
+
"inputs": {
|
| 38 |
+
"model": ["1", 0],
|
| 39 |
+
"positive": ["2", 0],
|
| 40 |
+
"negative": ["3", 0],
|
| 41 |
+
"latent_image": ["9", 0],
|
| 42 |
+
"seed": 0,
|
| 43 |
+
"steps": 28,
|
| 44 |
+
"cfg": 7.0,
|
| 45 |
+
"sampler_name": "dpmpp_2m",
|
| 46 |
+
"scheduler": "karras",
|
| 47 |
+
"denoise": 0.65
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"6": {
|
| 51 |
+
"class_type": "VAEDecode",
|
| 52 |
+
"inputs": {
|
| 53 |
+
"samples": ["5", 0],
|
| 54 |
+
"vae": ["1", 2]
|
| 55 |
+
}
|
| 56 |
+
},
|
| 57 |
+
"7": {
|
| 58 |
+
"class_type": "SaveImage",
|
| 59 |
+
"inputs": {
|
| 60 |
+
"images": ["6", 0],
|
| 61 |
+
"filename_prefix": "content_engine_img2img"
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.109.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
aiohttp>=3.9.0
|
| 4 |
+
sqlalchemy>=2.0.0
|
| 5 |
+
aiosqlite>=0.19.0
|
| 6 |
+
pydantic>=2.5.0
|
| 7 |
+
pydantic-settings>=2.1.0
|
| 8 |
+
jinja2>=3.1.0
|
| 9 |
+
Pillow>=10.2.0
|
| 10 |
+
httpx>=0.26.0
|
| 11 |
+
pyyaml>=6.0
|
| 12 |
+
python-multipart>=0.0.6
|
| 13 |
+
python-dotenv>=1.0.0
|
| 14 |
+
runpod>=1.6.0
|
| 15 |
+
paramiko>=3.4.0
|
src/content_engine.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: content-engine
|
| 3 |
+
Version: 0.1.0
|
| 4 |
+
Summary: Automated content generation system using ComfyUI
|
| 5 |
+
Requires-Python: >=3.11
|
| 6 |
+
Requires-Dist: fastapi>=0.109.0
|
| 7 |
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
| 8 |
+
Requires-Dist: aiohttp>=3.9.0
|
| 9 |
+
Requires-Dist: sqlalchemy>=2.0.0
|
| 10 |
+
Requires-Dist: alembic>=1.13.0
|
| 11 |
+
Requires-Dist: aiosqlite>=0.19.0
|
| 12 |
+
Requires-Dist: pydantic>=2.5.0
|
| 13 |
+
Requires-Dist: pydantic-settings>=2.1.0
|
| 14 |
+
Requires-Dist: jinja2>=3.1.0
|
| 15 |
+
Requires-Dist: Pillow>=10.2.0
|
| 16 |
+
Requires-Dist: apscheduler>=3.10.0
|
| 17 |
+
Requires-Dist: httpx>=0.26.0
|
| 18 |
+
Requires-Dist: pyyaml>=6.0
|
| 19 |
+
Requires-Dist: python-multipart>=0.0.6
|
| 20 |
+
Provides-Extra: cloud
|
| 21 |
+
Requires-Dist: replicate>=0.22.0; extra == "cloud"
|
| 22 |
+
Provides-Extra: dev
|
| 23 |
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
| 24 |
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
| 25 |
+
Requires-Dist: httpx>=0.26.0; extra == "dev"
|
src/content_engine.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pyproject.toml
|
| 2 |
+
src/content_engine/__init__.py
|
| 3 |
+
src/content_engine/config.py
|
| 4 |
+
src/content_engine/main.py
|
| 5 |
+
src/content_engine.egg-info/PKG-INFO
|
| 6 |
+
src/content_engine.egg-info/SOURCES.txt
|
| 7 |
+
src/content_engine.egg-info/dependency_links.txt
|
| 8 |
+
src/content_engine.egg-info/requires.txt
|
| 9 |
+
src/content_engine.egg-info/top_level.txt
|
| 10 |
+
src/content_engine/api/__init__.py
|
| 11 |
+
src/content_engine/api/routes_catalog.py
|
| 12 |
+
src/content_engine/api/routes_generation.py
|
| 13 |
+
src/content_engine/api/routes_system.py
|
| 14 |
+
src/content_engine/models/__init__.py
|
| 15 |
+
src/content_engine/models/database.py
|
| 16 |
+
src/content_engine/models/schemas.py
|
| 17 |
+
src/content_engine/services/__init__.py
|
| 18 |
+
src/content_engine/services/catalog.py
|
| 19 |
+
src/content_engine/services/comfyui_client.py
|
| 20 |
+
src/content_engine/services/router.py
|
| 21 |
+
src/content_engine/services/template_engine.py
|
| 22 |
+
src/content_engine/services/variation_engine.py
|
| 23 |
+
src/content_engine/services/workflow_builder.py
|
| 24 |
+
src/content_engine/services/cloud_providers/__init__.py
|
| 25 |
+
src/content_engine/services/cloud_providers/base.py
|
| 26 |
+
src/content_engine/services/publisher/__init__.py
|
| 27 |
+
src/content_engine/services/publisher/base.py
|
| 28 |
+
src/content_engine/workers/__init__.py
|
| 29 |
+
src/content_engine/workers/cloud_worker.py
|
| 30 |
+
src/content_engine/workers/local_worker.py
|
src/content_engine.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/content_engine.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.109.0
|
| 2 |
+
uvicorn[standard]>=0.27.0
|
| 3 |
+
aiohttp>=3.9.0
|
| 4 |
+
sqlalchemy>=2.0.0
|
| 5 |
+
alembic>=1.13.0
|
| 6 |
+
aiosqlite>=0.19.0
|
| 7 |
+
pydantic>=2.5.0
|
| 8 |
+
pydantic-settings>=2.1.0
|
| 9 |
+
jinja2>=3.1.0
|
| 10 |
+
Pillow>=10.2.0
|
| 11 |
+
apscheduler>=3.10.0
|
| 12 |
+
httpx>=0.26.0
|
| 13 |
+
pyyaml>=6.0
|
| 14 |
+
python-multipart>=0.0.6
|
| 15 |
+
|
| 16 |
+
[cloud]
|
| 17 |
+
replicate>=0.22.0
|
| 18 |
+
|
| 19 |
+
[dev]
|
| 20 |
+
pytest>=7.4.0
|
| 21 |
+
pytest-asyncio>=0.23.0
|
| 22 |
+
httpx>=0.26.0
|
src/content_engine.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
content_engine
|
src/content_engine/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content Engine - Automated content generation system using ComfyUI."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
src/content_engine/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (279 Bytes). View file
|
|
|
src/content_engine/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (6.33 kB). View file
|
|
|
src/content_engine/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
src/content_engine/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI route modules."""
|
src/content_engine/api/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (211 Bytes). View file
|
|
|
src/content_engine/api/__pycache__/routes_catalog.cpython-311.pyc
ADDED
|
Binary file (7.2 kB). View file
|
|
|
src/content_engine/api/__pycache__/routes_generation.cpython-311.pyc
ADDED
|
Binary file (23.7 kB). View file
|
|
|
src/content_engine/api/__pycache__/routes_pod.cpython-311.pyc
ADDED
|
Binary file (25.1 kB). View file
|
|
|
src/content_engine/api/__pycache__/routes_system.cpython-311.pyc
ADDED
|
Binary file (10.7 kB). View file
|
|
|
src/content_engine/api/__pycache__/routes_training.cpython-311.pyc
ADDED
|
Binary file (12.6 kB). View file
|
|
|
src/content_engine/api/__pycache__/routes_ui.cpython-311.pyc
ADDED
|
Binary file (1.23 kB). View file
|
|
|
src/content_engine/api/__pycache__/routes_video.cpython-311.pyc
ADDED
|
Binary file (13.3 kB). View file
|
|
|
src/content_engine/api/routes_catalog.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Catalog API routes — query and manage generated images."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, HTTPException
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
|
| 10 |
+
from content_engine.models.schemas import ImageResponse
|
| 11 |
+
|
| 12 |
+
router = APIRouter(prefix="/api", tags=["catalog"])
|
| 13 |
+
|
| 14 |
+
_catalog = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def init_routes(catalog):
|
| 18 |
+
"""Initialize route dependencies."""
|
| 19 |
+
global _catalog
|
| 20 |
+
_catalog = catalog
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("/images", response_model=list[ImageResponse])
|
| 24 |
+
async def list_images(
|
| 25 |
+
character_id: str | None = None,
|
| 26 |
+
content_rating: str | None = None,
|
| 27 |
+
template_id: str | None = None,
|
| 28 |
+
is_approved: bool | None = None,
|
| 29 |
+
is_published: bool | None = None,
|
| 30 |
+
pose: str | None = None,
|
| 31 |
+
outfit: str | None = None,
|
| 32 |
+
emotion: str | None = None,
|
| 33 |
+
limit: int = 50,
|
| 34 |
+
offset: int = 0,
|
| 35 |
+
):
|
| 36 |
+
"""Search and list generated images with optional filters."""
|
| 37 |
+
if _catalog is None:
|
| 38 |
+
raise HTTPException(503, "Catalog not initialized")
|
| 39 |
+
|
| 40 |
+
images = await _catalog.search(
|
| 41 |
+
character_id=character_id,
|
| 42 |
+
content_rating=content_rating,
|
| 43 |
+
template_id=template_id,
|
| 44 |
+
is_approved=is_approved,
|
| 45 |
+
is_published=is_published,
|
| 46 |
+
pose=pose,
|
| 47 |
+
outfit=outfit,
|
| 48 |
+
emotion=emotion,
|
| 49 |
+
limit=limit,
|
| 50 |
+
offset=offset,
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return [
|
| 54 |
+
ImageResponse(
|
| 55 |
+
id=img.id,
|
| 56 |
+
character_id=img.character_id,
|
| 57 |
+
template_id=img.template_id,
|
| 58 |
+
content_rating=img.content_rating,
|
| 59 |
+
file_path=img.file_path,
|
| 60 |
+
seed=img.seed,
|
| 61 |
+
pose=img.pose,
|
| 62 |
+
outfit=img.outfit,
|
| 63 |
+
emotion=img.emotion,
|
| 64 |
+
camera_angle=img.camera_angle,
|
| 65 |
+
lighting=img.lighting,
|
| 66 |
+
scene=img.scene,
|
| 67 |
+
quality_score=img.quality_score,
|
| 68 |
+
is_approved=img.is_approved,
|
| 69 |
+
is_published=img.is_published,
|
| 70 |
+
created_at=img.created_at,
|
| 71 |
+
)
|
| 72 |
+
for img in images
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/images/{image_id}", response_model=ImageResponse)
|
| 77 |
+
async def get_image(image_id: str):
|
| 78 |
+
"""Get a single image by ID."""
|
| 79 |
+
if _catalog is None:
|
| 80 |
+
raise HTTPException(503, "Catalog not initialized")
|
| 81 |
+
|
| 82 |
+
img = await _catalog.get_image(image_id)
|
| 83 |
+
if img is None:
|
| 84 |
+
raise HTTPException(404, f"Image not found: {image_id}")
|
| 85 |
+
|
| 86 |
+
return ImageResponse(
|
| 87 |
+
id=img.id,
|
| 88 |
+
character_id=img.character_id,
|
| 89 |
+
template_id=img.template_id,
|
| 90 |
+
content_rating=img.content_rating,
|
| 91 |
+
file_path=img.file_path,
|
| 92 |
+
seed=img.seed,
|
| 93 |
+
pose=img.pose,
|
| 94 |
+
outfit=img.outfit,
|
| 95 |
+
emotion=img.emotion,
|
| 96 |
+
camera_angle=img.camera_angle,
|
| 97 |
+
lighting=img.lighting,
|
| 98 |
+
scene=img.scene,
|
| 99 |
+
quality_score=img.quality_score,
|
| 100 |
+
is_approved=img.is_approved,
|
| 101 |
+
is_published=img.is_published,
|
| 102 |
+
created_at=img.created_at,
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@router.get("/images/{image_id}/file")
|
| 107 |
+
async def serve_image_file(image_id: str):
|
| 108 |
+
"""Serve the actual image file for display in the UI."""
|
| 109 |
+
if _catalog is None:
|
| 110 |
+
raise HTTPException(503, "Catalog not initialized")
|
| 111 |
+
|
| 112 |
+
img = await _catalog.get_image(image_id)
|
| 113 |
+
if img is None:
|
| 114 |
+
raise HTTPException(404, f"Image not found: {image_id}")
|
| 115 |
+
|
| 116 |
+
file_path = Path(img.file_path)
|
| 117 |
+
if not file_path.exists():
|
| 118 |
+
raise HTTPException(404, f"Image file not found on disk")
|
| 119 |
+
|
| 120 |
+
return FileResponse(
|
| 121 |
+
file_path,
|
| 122 |
+
media_type="image/png",
|
| 123 |
+
headers={"Cache-Control": "public, max-age=3600"},
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@router.get("/images/{image_id}/download")
|
| 128 |
+
async def download_image_file(image_id: str):
|
| 129 |
+
"""Download the image file as an attachment."""
|
| 130 |
+
if _catalog is None:
|
| 131 |
+
raise HTTPException(503, "Catalog not initialized")
|
| 132 |
+
|
| 133 |
+
img = await _catalog.get_image(image_id)
|
| 134 |
+
if img is None:
|
| 135 |
+
raise HTTPException(404, f"Image not found: {image_id}")
|
| 136 |
+
|
| 137 |
+
file_path = Path(img.file_path)
|
| 138 |
+
if not file_path.exists():
|
| 139 |
+
raise HTTPException(404, "Image file not found on disk")
|
| 140 |
+
|
| 141 |
+
return FileResponse(
|
| 142 |
+
file_path,
|
| 143 |
+
media_type="image/png",
|
| 144 |
+
filename=file_path.name,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@router.post("/images/{image_id}/approve")
|
| 149 |
+
async def approve_image(image_id: str):
|
| 150 |
+
"""Mark an image as approved for publishing."""
|
| 151 |
+
if _catalog is None:
|
| 152 |
+
raise HTTPException(503, "Catalog not initialized")
|
| 153 |
+
|
| 154 |
+
success = await _catalog.approve_image(image_id)
|
| 155 |
+
if not success:
|
| 156 |
+
raise HTTPException(404, f"Image not found: {image_id}")
|
| 157 |
+
return {"status": "approved", "image_id": image_id}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@router.delete("/images/{image_id}")
|
| 161 |
+
async def delete_image(image_id: str):
|
| 162 |
+
"""Delete an image from the catalog and disk."""
|
| 163 |
+
if _catalog is None:
|
| 164 |
+
raise HTTPException(503, "Catalog not initialized")
|
| 165 |
+
|
| 166 |
+
success = await _catalog.delete_image(image_id)
|
| 167 |
+
if not success:
|
| 168 |
+
raise HTTPException(404, f"Image not found: {image_id}")
|
| 169 |
+
return {"status": "deleted", "image_id": image_id}
|
src/content_engine/api/routes_generation.py
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generation API routes — submit single and batch image generation jobs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
| 10 |
+
|
| 11 |
+
from content_engine.models.schemas import (
|
| 12 |
+
BatchRequest,
|
| 13 |
+
BatchStatusResponse,
|
| 14 |
+
GenerationRequest,
|
| 15 |
+
GenerationResponse,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api", tags=["generation"])
|
| 21 |
+
|
| 22 |
+
# These are injected at startup from main.py
|
| 23 |
+
_local_worker = None
|
| 24 |
+
_template_engine = None
|
| 25 |
+
_variation_engine = None
|
| 26 |
+
_character_profiles = None
|
| 27 |
+
_wavespeed_provider = None
|
| 28 |
+
_runpod_provider = None
|
| 29 |
+
_catalog = None
|
| 30 |
+
_comfyui_client = None
|
| 31 |
+
|
| 32 |
+
# In-memory batch tracking (v1 — move to DB for production)
|
| 33 |
+
_batch_tracker: dict[str, dict] = {}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def init_routes(local_worker, template_engine, variation_engine, character_profiles,
|
| 37 |
+
wavespeed_provider=None, catalog=None, comfyui_client=None):
|
| 38 |
+
"""Initialize route dependencies. Called from main.py on startup."""
|
| 39 |
+
global _local_worker, _template_engine, _variation_engine, _character_profiles
|
| 40 |
+
global _wavespeed_provider, _catalog, _comfyui_client
|
| 41 |
+
_local_worker = local_worker
|
| 42 |
+
_template_engine = template_engine
|
| 43 |
+
_variation_engine = variation_engine
|
| 44 |
+
_character_profiles = character_profiles
|
| 45 |
+
_wavespeed_provider = wavespeed_provider
|
| 46 |
+
_catalog = catalog
|
| 47 |
+
_comfyui_client = comfyui_client
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def set_runpod_provider(provider):
|
| 51 |
+
"""Set RunPod generation provider. Called from main.py after init_routes."""
|
| 52 |
+
global _runpod_provider
|
| 53 |
+
_runpod_provider = provider
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/generate", response_model=GenerationResponse)
|
| 57 |
+
async def generate_single(request: GenerationRequest):
|
| 58 |
+
"""Submit a single image generation job.
|
| 59 |
+
|
| 60 |
+
The job runs asynchronously — returns immediately with a job ID.
|
| 61 |
+
"""
|
| 62 |
+
if _local_worker is None:
|
| 63 |
+
raise HTTPException(503, "Worker not initialized")
|
| 64 |
+
|
| 65 |
+
job_id = str(uuid.uuid4())
|
| 66 |
+
|
| 67 |
+
# Fire and forget — run in background
|
| 68 |
+
asyncio.create_task(
|
| 69 |
+
_run_generation(
|
| 70 |
+
job_id=job_id,
|
| 71 |
+
character_id=request.character_id,
|
| 72 |
+
template_id=request.template_id,
|
| 73 |
+
content_rating=request.content_rating,
|
| 74 |
+
positive_prompt=request.positive_prompt,
|
| 75 |
+
negative_prompt=request.negative_prompt,
|
| 76 |
+
checkpoint=request.checkpoint,
|
| 77 |
+
loras=[l.model_dump() for l in request.loras] if request.loras else None,
|
| 78 |
+
seed=request.seed or -1,
|
| 79 |
+
steps=request.steps,
|
| 80 |
+
cfg=request.cfg,
|
| 81 |
+
sampler=request.sampler,
|
| 82 |
+
scheduler=request.scheduler,
|
| 83 |
+
width=request.width,
|
| 84 |
+
height=request.height,
|
| 85 |
+
variables=request.variables,
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
return GenerationResponse(job_id=job_id, status="queued", backend="local")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.post("/batch", response_model=GenerationResponse)
|
| 93 |
+
async def generate_batch(request: BatchRequest):
|
| 94 |
+
"""Submit a batch of variation-based generation jobs.
|
| 95 |
+
|
| 96 |
+
Uses the variation engine to generate multiple images with
|
| 97 |
+
different poses, outfits, emotions, etc.
|
| 98 |
+
"""
|
| 99 |
+
if _local_worker is None or _variation_engine is None:
|
| 100 |
+
raise HTTPException(503, "Services not initialized")
|
| 101 |
+
if _character_profiles is None:
|
| 102 |
+
raise HTTPException(503, "No character profiles loaded")
|
| 103 |
+
|
| 104 |
+
character = _character_profiles.get(request.character_id)
|
| 105 |
+
if character is None:
|
| 106 |
+
raise HTTPException(404, f"Character not found: {request.character_id}")
|
| 107 |
+
|
| 108 |
+
# Generate variation jobs
|
| 109 |
+
jobs = _variation_engine.generate_batch(
|
| 110 |
+
template_id=request.template_id,
|
| 111 |
+
character=character,
|
| 112 |
+
content_rating=request.content_rating,
|
| 113 |
+
count=request.count,
|
| 114 |
+
variation_mode=request.variation_mode,
|
| 115 |
+
pin=request.pin,
|
| 116 |
+
seed_strategy=request.seed_strategy,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
batch_id = jobs[0].batch_id if jobs else str(uuid.uuid4())
|
| 120 |
+
_batch_tracker[batch_id] = {
|
| 121 |
+
"total": len(jobs),
|
| 122 |
+
"completed": 0,
|
| 123 |
+
"failed": 0,
|
| 124 |
+
"pending": len(jobs),
|
| 125 |
+
"running": 0,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
# Fire all jobs in background
|
| 129 |
+
for job in jobs:
|
| 130 |
+
asyncio.create_task(
|
| 131 |
+
_run_batch_job(batch_id, job)
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
logger.info("Batch %s: %d jobs queued", batch_id, len(jobs))
|
| 135 |
+
return GenerationResponse(
|
| 136 |
+
job_id=batch_id, batch_id=batch_id, status="queued", backend="local"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.get("/batch/{batch_id}/status", response_model=BatchStatusResponse)
|
| 141 |
+
async def get_batch_status(batch_id: str):
|
| 142 |
+
"""Get the status of a batch generation."""
|
| 143 |
+
if batch_id not in _batch_tracker:
|
| 144 |
+
raise HTTPException(404, f"Batch not found: {batch_id}")
|
| 145 |
+
tracker = _batch_tracker[batch_id]
|
| 146 |
+
return BatchStatusResponse(
|
| 147 |
+
batch_id=batch_id,
|
| 148 |
+
total_jobs=tracker["total"],
|
| 149 |
+
completed=tracker["completed"],
|
| 150 |
+
failed=tracker["failed"],
|
| 151 |
+
pending=tracker["pending"],
|
| 152 |
+
running=tracker["running"],
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@router.post("/generate/cloud", response_model=GenerationResponse)
|
| 157 |
+
async def generate_cloud(request: GenerationRequest):
|
| 158 |
+
"""Generate an image using WaveSpeed cloud API (NanoBanana, SeeDream).
|
| 159 |
+
|
| 160 |
+
Supported models via the 'checkpoint' field:
|
| 161 |
+
- nano-banana, nano-banana-pro
|
| 162 |
+
- seedream-3, seedream-3.1, seedream-4, seedream-4.5
|
| 163 |
+
"""
|
| 164 |
+
if _wavespeed_provider is None:
|
| 165 |
+
raise HTTPException(503, "WaveSpeed cloud provider not configured. Set WAVESPEED_API_KEY in .env")
|
| 166 |
+
|
| 167 |
+
job_id = str(uuid.uuid4())
|
| 168 |
+
|
| 169 |
+
asyncio.create_task(
|
| 170 |
+
_run_cloud_generation(
|
| 171 |
+
job_id=job_id,
|
| 172 |
+
positive_prompt=request.positive_prompt or "",
|
| 173 |
+
negative_prompt=request.negative_prompt or "",
|
| 174 |
+
model=request.checkpoint, # Use checkpoint field for model selection
|
| 175 |
+
width=request.width or 1024,
|
| 176 |
+
height=request.height or 1024,
|
| 177 |
+
seed=request.seed or -1,
|
| 178 |
+
content_rating=request.content_rating,
|
| 179 |
+
character_id=request.character_id,
|
| 180 |
+
template_id=request.template_id,
|
| 181 |
+
variables=request.variables,
|
| 182 |
+
)
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
return GenerationResponse(job_id=job_id, status="queued", backend="wavespeed")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
@router.get("/cloud/models")
|
| 189 |
+
async def list_cloud_models():
|
| 190 |
+
"""List available cloud models (WaveSpeed and RunPod)."""
|
| 191 |
+
return {
|
| 192 |
+
"wavespeed": {
|
| 193 |
+
"available": _wavespeed_provider is not None,
|
| 194 |
+
"models": [
|
| 195 |
+
{"id": "nano-banana", "name": "NanoBanana", "provider": "Google", "type": "txt2img"},
|
| 196 |
+
{"id": "nano-banana-pro", "name": "NanoBanana Pro", "provider": "Google", "type": "txt2img"},
|
| 197 |
+
{"id": "seedream-3", "name": "SeeDream v3", "provider": "ByteDance", "type": "txt2img"},
|
| 198 |
+
{"id": "seedream-3.1", "name": "SeeDream v3.1", "provider": "ByteDance", "type": "txt2img"},
|
| 199 |
+
{"id": "seedream-4", "name": "SeeDream v4", "provider": "ByteDance", "type": "txt2img"},
|
| 200 |
+
{"id": "seedream-4.5", "name": "SeeDream v4.5", "provider": "ByteDance", "type": "txt2img"},
|
| 201 |
+
],
|
| 202 |
+
"edit_models": [
|
| 203 |
+
{"id": "seedream-4.5-edit", "name": "SeeDream v4.5 Edit", "provider": "ByteDance", "type": "img2img", "price": "$0.04/img"},
|
| 204 |
+
{"id": "seedream-4-edit", "name": "SeeDream v4 Edit", "provider": "ByteDance", "type": "img2img", "price": "$0.04/img"},
|
| 205 |
+
{"id": "nano-banana-edit", "name": "NanoBanana Edit", "provider": "Google", "type": "img2img", "price": "$0.038/img"},
|
| 206 |
+
{"id": "nano-banana-pro-edit", "name": "NanoBanana Pro Edit", "provider": "Google", "type": "img2img", "price": "$0.14/img"},
|
| 207 |
+
],
|
| 208 |
+
},
|
| 209 |
+
"runpod": {
|
| 210 |
+
"available": _runpod_provider is not None,
|
| 211 |
+
"description": "Pay-per-second serverless GPU. Uses your deployed endpoint.",
|
| 212 |
+
"pricing": "~$0.00025/sec (RTX 4090)",
|
| 213 |
+
},
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@router.post("/generate/runpod", response_model=GenerationResponse)
|
| 218 |
+
async def generate_runpod(request: GenerationRequest):
|
| 219 |
+
"""Generate an image using RunPod serverless GPU.
|
| 220 |
+
|
| 221 |
+
Uses your deployed RunPod endpoint. Pay per second of GPU time.
|
| 222 |
+
Requires RUNPOD_API_KEY and RUNPOD_ENDPOINT_ID in .env.
|
| 223 |
+
"""
|
| 224 |
+
if _runpod_provider is None:
|
| 225 |
+
raise HTTPException(
|
| 226 |
+
503,
|
| 227 |
+
"RunPod not configured. Set RUNPOD_API_KEY and RUNPOD_ENDPOINT_ID in .env"
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
job_id = str(uuid.uuid4())
|
| 231 |
+
|
| 232 |
+
asyncio.create_task(
|
| 233 |
+
_run_runpod_generation(
|
| 234 |
+
job_id=job_id,
|
| 235 |
+
positive_prompt=request.positive_prompt or "",
|
| 236 |
+
negative_prompt=request.negative_prompt or "",
|
| 237 |
+
checkpoint=request.checkpoint,
|
| 238 |
+
loras=request.loras,
|
| 239 |
+
seed=request.seed or -1,
|
| 240 |
+
steps=request.steps or 28,
|
| 241 |
+
cfg=request.cfg or 7.0,
|
| 242 |
+
width=request.width or 832,
|
| 243 |
+
height=request.height or 1216,
|
| 244 |
+
character_id=request.character_id,
|
| 245 |
+
template_id=request.template_id,
|
| 246 |
+
content_rating=request.content_rating,
|
| 247 |
+
)
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return GenerationResponse(job_id=job_id, status="queued", backend="runpod")
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@router.post("/generate/img2img", response_model=GenerationResponse)
|
| 254 |
+
async def generate_img2img(
|
| 255 |
+
image: UploadFile = File(...),
|
| 256 |
+
positive_prompt: str = Form(""),
|
| 257 |
+
negative_prompt: str = Form(""),
|
| 258 |
+
character_id: str | None = Form(None),
|
| 259 |
+
template_id: str | None = Form(None),
|
| 260 |
+
variables_json: str = Form("{}"),
|
| 261 |
+
content_rating: str = Form("sfw"),
|
| 262 |
+
checkpoint: str | None = Form(None),
|
| 263 |
+
seed: int = Form(-1),
|
| 264 |
+
steps: int = Form(28),
|
| 265 |
+
cfg: float = Form(7.0),
|
| 266 |
+
denoise: float = Form(0.65),
|
| 267 |
+
width: int | None = Form(None),
|
| 268 |
+
height: int | None = Form(None),
|
| 269 |
+
backend: str = Form("local"),
|
| 270 |
+
):
|
| 271 |
+
"""Generate an image using a reference image (img2img).
|
| 272 |
+
|
| 273 |
+
Supports both local (ComfyUI) and cloud (WaveSpeed edit) backends.
|
| 274 |
+
- Local: denoise-based img2img via ComfyUI
|
| 275 |
+
- Cloud: prompt-guided editing via SeeDream/NanoBanana Edit APIs
|
| 276 |
+
"""
|
| 277 |
+
import json as json_module
|
| 278 |
+
|
| 279 |
+
job_id = str(uuid.uuid4())
|
| 280 |
+
image_bytes = await image.read()
|
| 281 |
+
|
| 282 |
+
# Parse template variables
|
| 283 |
+
try:
|
| 284 |
+
variables = json_module.loads(variables_json) if variables_json else {}
|
| 285 |
+
except json_module.JSONDecodeError:
|
| 286 |
+
variables = {}
|
| 287 |
+
|
| 288 |
+
if backend == "cloud":
|
| 289 |
+
# Cloud img2img via WaveSpeed Edit API
|
| 290 |
+
if _wavespeed_provider is None:
|
| 291 |
+
raise HTTPException(503, "WaveSpeed cloud provider not configured. Set WAVESPEED_API_KEY in .env")
|
| 292 |
+
|
| 293 |
+
asyncio.create_task(
|
| 294 |
+
_run_cloud_img2img(
|
| 295 |
+
job_id=job_id,
|
| 296 |
+
image_bytes=image_bytes,
|
| 297 |
+
positive_prompt=positive_prompt,
|
| 298 |
+
model=checkpoint,
|
| 299 |
+
content_rating=content_rating,
|
| 300 |
+
character_id=character_id,
|
| 301 |
+
template_id=template_id,
|
| 302 |
+
variables=variables,
|
| 303 |
+
width=width,
|
| 304 |
+
height=height,
|
| 305 |
+
)
|
| 306 |
+
)
|
| 307 |
+
return GenerationResponse(job_id=job_id, status="queued", backend="wavespeed")
|
| 308 |
+
|
| 309 |
+
# Local img2img via ComfyUI
|
| 310 |
+
if _local_worker is None or _comfyui_client is None:
|
| 311 |
+
raise HTTPException(503, "Worker not initialized")
|
| 312 |
+
|
| 313 |
+
ref_filename = f"ref_{job_id[:8]}.png"
|
| 314 |
+
|
| 315 |
+
try:
|
| 316 |
+
uploaded_name = await _comfyui_client.upload_image(image_bytes, ref_filename)
|
| 317 |
+
except Exception as e:
|
| 318 |
+
raise HTTPException(500, f"Failed to upload reference image to ComfyUI: {e}")
|
| 319 |
+
|
| 320 |
+
asyncio.create_task(
|
| 321 |
+
_run_generation(
|
| 322 |
+
job_id=job_id,
|
| 323 |
+
character_id=character_id,
|
| 324 |
+
template_id=template_id,
|
| 325 |
+
variables=variables,
|
| 326 |
+
content_rating=content_rating,
|
| 327 |
+
positive_prompt=positive_prompt,
|
| 328 |
+
negative_prompt=negative_prompt,
|
| 329 |
+
checkpoint=checkpoint,
|
| 330 |
+
seed=seed,
|
| 331 |
+
steps=steps,
|
| 332 |
+
cfg=cfg,
|
| 333 |
+
width=width,
|
| 334 |
+
height=height,
|
| 335 |
+
denoise=denoise,
|
| 336 |
+
reference_image=uploaded_name,
|
| 337 |
+
mode="img2img",
|
| 338 |
+
)
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
return GenerationResponse(job_id=job_id, status="queued", backend="local")
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
async def _run_cloud_generation(
|
| 345 |
+
*,
|
| 346 |
+
job_id: str,
|
| 347 |
+
positive_prompt: str,
|
| 348 |
+
negative_prompt: str,
|
| 349 |
+
model: str | None,
|
| 350 |
+
width: int,
|
| 351 |
+
height: int,
|
| 352 |
+
seed: int,
|
| 353 |
+
content_rating: str,
|
| 354 |
+
character_id: str | None,
|
| 355 |
+
template_id: str | None,
|
| 356 |
+
variables: dict | None,
|
| 357 |
+
):
|
| 358 |
+
"""Background task to run a WaveSpeed cloud generation."""
|
| 359 |
+
try:
|
| 360 |
+
# Apply template rendering if a template is selected
|
| 361 |
+
final_positive = positive_prompt
|
| 362 |
+
final_negative = negative_prompt
|
| 363 |
+
if template_id and _template_engine:
|
| 364 |
+
try:
|
| 365 |
+
rendered = _template_engine.render(template_id, variables or {})
|
| 366 |
+
# Template prompt becomes the base; user prompt is appended if provided
|
| 367 |
+
final_positive = rendered.positive_prompt
|
| 368 |
+
if positive_prompt:
|
| 369 |
+
final_positive = f"{final_positive}, {positive_prompt}"
|
| 370 |
+
final_negative = rendered.negative_prompt
|
| 371 |
+
if negative_prompt:
|
| 372 |
+
final_negative = f"{final_negative}, {negative_prompt}"
|
| 373 |
+
# Use template dimensions if user didn't override
|
| 374 |
+
if rendered.template.width:
|
| 375 |
+
width = rendered.template.width
|
| 376 |
+
if rendered.template.height:
|
| 377 |
+
height = rendered.template.height
|
| 378 |
+
logger.info("Cloud gen: applied template '%s'", template_id)
|
| 379 |
+
except Exception:
|
| 380 |
+
logger.warning("Failed to render template '%s', using raw prompt", template_id, exc_info=True)
|
| 381 |
+
|
| 382 |
+
result = await _wavespeed_provider.generate(
|
| 383 |
+
positive_prompt=final_positive,
|
| 384 |
+
negative_prompt=final_negative,
|
| 385 |
+
model=model,
|
| 386 |
+
width=width,
|
| 387 |
+
height=height,
|
| 388 |
+
seed=seed,
|
| 389 |
+
)
|
| 390 |
+
|
| 391 |
+
if _catalog:
|
| 392 |
+
# Save image to disk
|
| 393 |
+
output_path = _catalog.resolve_output_path(
|
| 394 |
+
character_id=character_id or "cloud",
|
| 395 |
+
content_rating=content_rating,
|
| 396 |
+
filename=f"wavespeed_{job_id[:8]}.png",
|
| 397 |
+
)
|
| 398 |
+
output_path.write_bytes(result.image_bytes)
|
| 399 |
+
|
| 400 |
+
# Record in catalog
|
| 401 |
+
await _catalog.insert_image(
|
| 402 |
+
file_path=str(output_path),
|
| 403 |
+
image_bytes=result.image_bytes,
|
| 404 |
+
character_id=character_id,
|
| 405 |
+
template_id=template_id,
|
| 406 |
+
content_rating=content_rating,
|
| 407 |
+
positive_prompt=positive_prompt,
|
| 408 |
+
negative_prompt=negative_prompt,
|
| 409 |
+
checkpoint=model or "seedream-4.5",
|
| 410 |
+
seed=seed if seed >= 0 else None,
|
| 411 |
+
width=width,
|
| 412 |
+
height=height,
|
| 413 |
+
generation_backend="wavespeed",
|
| 414 |
+
generation_time_seconds=result.generation_time_seconds,
|
| 415 |
+
variables=variables,
|
| 416 |
+
)
|
| 417 |
+
logger.info("Cloud generation saved: %s", output_path)
|
| 418 |
+
|
| 419 |
+
except Exception:
|
| 420 |
+
logger.error("Cloud generation failed for job %s", job_id, exc_info=True)
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
async def _run_cloud_img2img(
|
| 424 |
+
*,
|
| 425 |
+
job_id: str,
|
| 426 |
+
image_bytes: bytes,
|
| 427 |
+
positive_prompt: str,
|
| 428 |
+
model: str | None,
|
| 429 |
+
content_rating: str,
|
| 430 |
+
character_id: str | None,
|
| 431 |
+
template_id: str | None,
|
| 432 |
+
variables: dict | None,
|
| 433 |
+
width: int | None,
|
| 434 |
+
height: int | None,
|
| 435 |
+
):
|
| 436 |
+
"""Background task to run a WaveSpeed cloud image edit (img2img)."""
|
| 437 |
+
try:
|
| 438 |
+
# Apply template rendering if a template is selected
|
| 439 |
+
final_prompt = positive_prompt
|
| 440 |
+
if template_id and _template_engine:
|
| 441 |
+
try:
|
| 442 |
+
rendered = _template_engine.render(template_id, variables or {})
|
| 443 |
+
final_prompt = rendered.positive_prompt
|
| 444 |
+
if positive_prompt:
|
| 445 |
+
final_prompt = f"{final_prompt}, {positive_prompt}"
|
| 446 |
+
logger.info("Cloud img2img: applied template '%s'", template_id)
|
| 447 |
+
except Exception:
|
| 448 |
+
logger.warning("Failed to render template '%s', using raw prompt", template_id, exc_info=True)
|
| 449 |
+
|
| 450 |
+
# Clean up prompt — remove empty Jinja2 artifacts and leading/trailing commas
|
| 451 |
+
final_prompt = ", ".join(p.strip() for p in final_prompt.split(",") if p.strip())
|
| 452 |
+
|
| 453 |
+
if not final_prompt:
|
| 454 |
+
logger.error("Cloud img2img: empty prompt after template rendering, cannot proceed")
|
| 455 |
+
return
|
| 456 |
+
|
| 457 |
+
# Build size string if dimensions provided
|
| 458 |
+
# WaveSpeed edit API requires output size >= 3686400 pixels (~1920x1920)
|
| 459 |
+
# If dimensions are too small, omit size to let API use input image dimensions
|
| 460 |
+
size = None
|
| 461 |
+
if width and height and (width * height) >= 3686400:
|
| 462 |
+
size = f"{width}x{height}"
|
| 463 |
+
|
| 464 |
+
result = await _wavespeed_provider.edit_image(
|
| 465 |
+
prompt=final_prompt,
|
| 466 |
+
image_bytes=image_bytes,
|
| 467 |
+
model=model,
|
| 468 |
+
size=size,
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
if _catalog:
|
| 472 |
+
output_path = _catalog.resolve_output_path(
|
| 473 |
+
character_id=character_id or "cloud",
|
| 474 |
+
content_rating=content_rating,
|
| 475 |
+
filename=f"wavespeed_edit_{job_id[:8]}.png",
|
| 476 |
+
)
|
| 477 |
+
output_path.write_bytes(result.image_bytes)
|
| 478 |
+
|
| 479 |
+
await _catalog.insert_image(
|
| 480 |
+
file_path=str(output_path),
|
| 481 |
+
image_bytes=result.image_bytes,
|
| 482 |
+
character_id=character_id,
|
| 483 |
+
template_id=template_id,
|
| 484 |
+
content_rating=content_rating,
|
| 485 |
+
positive_prompt=final_prompt,
|
| 486 |
+
negative_prompt="",
|
| 487 |
+
checkpoint=model or "seedream-4.5-edit",
|
| 488 |
+
width=width or 0,
|
| 489 |
+
height=height or 0,
|
| 490 |
+
generation_backend="wavespeed-edit",
|
| 491 |
+
generation_time_seconds=result.generation_time_seconds,
|
| 492 |
+
variables=variables,
|
| 493 |
+
)
|
| 494 |
+
logger.info("Cloud img2img saved: %s", output_path)
|
| 495 |
+
|
| 496 |
+
except Exception:
|
| 497 |
+
logger.error("Cloud img2img failed for job %s", job_id, exc_info=True)
|
| 498 |
+
|
| 499 |
+
|
| 500 |
+
async def _run_runpod_generation(
|
| 501 |
+
*,
|
| 502 |
+
job_id: str,
|
| 503 |
+
positive_prompt: str,
|
| 504 |
+
negative_prompt: str,
|
| 505 |
+
checkpoint: str | None,
|
| 506 |
+
loras: list | None,
|
| 507 |
+
seed: int,
|
| 508 |
+
steps: int,
|
| 509 |
+
cfg: float,
|
| 510 |
+
width: int,
|
| 511 |
+
height: int,
|
| 512 |
+
character_id: str | None,
|
| 513 |
+
template_id: str | None,
|
| 514 |
+
content_rating: str,
|
| 515 |
+
):
|
| 516 |
+
"""Background task to run a generation on RunPod serverless."""
|
| 517 |
+
try:
|
| 518 |
+
# Resolve character/template prompts if provided
|
| 519 |
+
final_prompt = positive_prompt
|
| 520 |
+
final_negative = negative_prompt
|
| 521 |
+
|
| 522 |
+
if character_id and _character_profiles:
|
| 523 |
+
character = _character_profiles.get(character_id)
|
| 524 |
+
if character:
|
| 525 |
+
final_prompt = f"{character.trigger_word}, {positive_prompt}"
|
| 526 |
+
|
| 527 |
+
# Submit to RunPod
|
| 528 |
+
runpod_job_id = await _runpod_provider.submit_generation(
|
| 529 |
+
positive_prompt=final_prompt,
|
| 530 |
+
negative_prompt=final_negative,
|
| 531 |
+
checkpoint=checkpoint or "realisticVisionV51_v51VAE",
|
| 532 |
+
lora_name=loras[0].name if loras else None,
|
| 533 |
+
lora_strength=loras[0].strength if loras else 0.85,
|
| 534 |
+
seed=seed,
|
| 535 |
+
steps=steps,
|
| 536 |
+
cfg=cfg,
|
| 537 |
+
width=width,
|
| 538 |
+
height=height,
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
# Wait for completion and get result
|
| 542 |
+
result = await _runpod_provider.wait_for_completion(runpod_job_id)
|
| 543 |
+
|
| 544 |
+
# Save to catalog
|
| 545 |
+
if _catalog:
|
| 546 |
+
from pathlib import Path
|
| 547 |
+
output_path = await _catalog.insert_image(
|
| 548 |
+
image_bytes=result.image_bytes,
|
| 549 |
+
character_id=character_id or "unknown",
|
| 550 |
+
content_rating=content_rating,
|
| 551 |
+
job_id=job_id,
|
| 552 |
+
positive_prompt=final_prompt,
|
| 553 |
+
negative_prompt=final_negative,
|
| 554 |
+
checkpoint=checkpoint,
|
| 555 |
+
seed=seed,
|
| 556 |
+
steps=steps,
|
| 557 |
+
cfg=cfg,
|
| 558 |
+
width=width,
|
| 559 |
+
height=height,
|
| 560 |
+
generation_backend="runpod",
|
| 561 |
+
generation_time_seconds=result.generation_time_seconds,
|
| 562 |
+
)
|
| 563 |
+
logger.info("RunPod generation saved: %s (%.1fs)", output_path, result.generation_time_seconds)
|
| 564 |
+
|
| 565 |
+
except Exception:
|
| 566 |
+
logger.error("RunPod generation failed for job %s", job_id, exc_info=True)
|
| 567 |
+
|
| 568 |
+
|
| 569 |
+
async def _run_generation(**kwargs):
|
| 570 |
+
"""Background task to run a single local generation."""
|
| 571 |
+
try:
|
| 572 |
+
# Remove mode param — it's used by the router, not the worker
|
| 573 |
+
kwargs.pop("mode", None)
|
| 574 |
+
await _local_worker.process_job(**kwargs)
|
| 575 |
+
except Exception:
|
| 576 |
+
logger.error("Generation failed for job %s", kwargs.get("job_id"), exc_info=True)
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
async def _run_batch_job(batch_id: str, job):
|
| 580 |
+
"""Background task to run a single job within a batch."""
|
| 581 |
+
tracker = _batch_tracker.get(batch_id)
|
| 582 |
+
if tracker:
|
| 583 |
+
tracker["pending"] -= 1
|
| 584 |
+
tracker["running"] += 1
|
| 585 |
+
try:
|
| 586 |
+
await _local_worker.process_job(
|
| 587 |
+
job_id=job.job_id,
|
| 588 |
+
batch_id=job.batch_id,
|
| 589 |
+
character_id=job.character.id,
|
| 590 |
+
template_id=job.template_id,
|
| 591 |
+
content_rating=job.content_rating,
|
| 592 |
+
loras=[l for l in job.loras],
|
| 593 |
+
seed=job.seed,
|
| 594 |
+
variables=job.variables,
|
| 595 |
+
)
|
| 596 |
+
if tracker:
|
| 597 |
+
tracker["completed"] += 1
|
| 598 |
+
except Exception:
|
| 599 |
+
logger.error("Batch job %s failed", job.job_id, exc_info=True)
|
| 600 |
+
if tracker:
|
| 601 |
+
tracker["failed"] += 1
|
| 602 |
+
finally:
|
| 603 |
+
if tracker:
|
| 604 |
+
tracker["running"] -= 1
|
src/content_engine/api/routes_pod.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RunPod Pod management routes — start/stop GPU pods for generation and training."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import runpod
|
| 13 |
+
from fastapi import APIRouter, File, HTTPException, UploadFile
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/pod", tags=["pod"])
|
| 19 |
+
|
| 20 |
+
# Pod state
|
| 21 |
+
_pod_state = {
|
| 22 |
+
"pod_id": None,
|
| 23 |
+
"status": "stopped", # stopped, starting, running, stopping
|
| 24 |
+
"ip": None,
|
| 25 |
+
"port": None,
|
| 26 |
+
"gpu_type": "NVIDIA GeForce RTX 4090",
|
| 27 |
+
"started_at": None,
|
| 28 |
+
"cost_per_hour": 0.44,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
# Docker image with ComfyUI + FLUX
|
| 32 |
+
COMFYUI_IMAGE = "timpietruskyblibla/runpod-worker-comfy:3.4.0-flux1-dev"
|
| 33 |
+
|
| 34 |
+
# GPU options
|
| 35 |
+
GPU_OPTIONS = {
|
| 36 |
+
"NVIDIA GeForce RTX 4090": {"name": "RTX 4090", "vram": 24, "cost": 0.44},
|
| 37 |
+
"NVIDIA RTX A6000": {"name": "RTX A6000", "vram": 48, "cost": 0.76},
|
| 38 |
+
"NVIDIA A100 80GB PCIe": {"name": "A100 80GB", "vram": 80, "cost": 1.89},
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _get_api_key() -> str:
|
| 43 |
+
key = os.environ.get("RUNPOD_API_KEY")
|
| 44 |
+
if not key:
|
| 45 |
+
raise HTTPException(503, "RUNPOD_API_KEY not configured")
|
| 46 |
+
runpod.api_key = key
|
| 47 |
+
return key
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class StartPodRequest(BaseModel):
|
| 51 |
+
gpu_type: str = "NVIDIA GeForce RTX 4090"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class PodStatus(BaseModel):
|
| 55 |
+
status: str
|
| 56 |
+
pod_id: str | None = None
|
| 57 |
+
ip: str | None = None
|
| 58 |
+
port: int | None = None
|
| 59 |
+
gpu_type: str | None = None
|
| 60 |
+
cost_per_hour: float | None = None
|
| 61 |
+
uptime_minutes: float | None = None
|
| 62 |
+
comfyui_url: str | None = None
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/status", response_model=PodStatus)
|
| 66 |
+
async def get_pod_status():
|
| 67 |
+
"""Get current pod status."""
|
| 68 |
+
_get_api_key()
|
| 69 |
+
|
| 70 |
+
# If we have a pod_id, check its actual status
|
| 71 |
+
if _pod_state["pod_id"]:
|
| 72 |
+
try:
|
| 73 |
+
pod = runpod.get_pod(_pod_state["pod_id"])
|
| 74 |
+
if pod:
|
| 75 |
+
desired = pod.get("desiredStatus", "")
|
| 76 |
+
if desired == "RUNNING":
|
| 77 |
+
runtime = pod.get("runtime", {})
|
| 78 |
+
ports = runtime.get("ports", [])
|
| 79 |
+
for p in ports:
|
| 80 |
+
if p.get("privatePort") == 8188:
|
| 81 |
+
_pod_state["ip"] = p.get("ip")
|
| 82 |
+
_pod_state["port"] = p.get("publicPort")
|
| 83 |
+
_pod_state["status"] = "running"
|
| 84 |
+
elif desired == "EXITED":
|
| 85 |
+
_pod_state["status"] = "stopped"
|
| 86 |
+
_pod_state["pod_id"] = None
|
| 87 |
+
else:
|
| 88 |
+
_pod_state["status"] = "stopped"
|
| 89 |
+
_pod_state["pod_id"] = None
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.warning("Failed to check pod: %s", e)
|
| 92 |
+
|
| 93 |
+
uptime = None
|
| 94 |
+
if _pod_state["started_at"] and _pod_state["status"] == "running":
|
| 95 |
+
uptime = (time.time() - _pod_state["started_at"]) / 60
|
| 96 |
+
|
| 97 |
+
comfyui_url = None
|
| 98 |
+
if _pod_state["ip"] and _pod_state["port"]:
|
| 99 |
+
comfyui_url = f"http://{_pod_state['ip']}:{_pod_state['port']}"
|
| 100 |
+
|
| 101 |
+
return PodStatus(
|
| 102 |
+
status=_pod_state["status"],
|
| 103 |
+
pod_id=_pod_state["pod_id"],
|
| 104 |
+
ip=_pod_state["ip"],
|
| 105 |
+
port=_pod_state["port"],
|
| 106 |
+
gpu_type=_pod_state["gpu_type"],
|
| 107 |
+
cost_per_hour=_pod_state["cost_per_hour"],
|
| 108 |
+
uptime_minutes=uptime,
|
| 109 |
+
comfyui_url=comfyui_url,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@router.get("/gpu-options")
|
| 114 |
+
async def list_gpu_options():
|
| 115 |
+
"""List available GPU types."""
|
| 116 |
+
return {"gpus": GPU_OPTIONS}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@router.post("/start")
|
| 120 |
+
async def start_pod(request: StartPodRequest):
|
| 121 |
+
"""Start a GPU pod for generation/training."""
|
| 122 |
+
_get_api_key()
|
| 123 |
+
|
| 124 |
+
if _pod_state["status"] == "running":
|
| 125 |
+
return {"status": "already_running", "pod_id": _pod_state["pod_id"]}
|
| 126 |
+
|
| 127 |
+
if _pod_state["status"] == "starting":
|
| 128 |
+
return {"status": "starting", "message": "Pod is already starting"}
|
| 129 |
+
|
| 130 |
+
gpu_info = GPU_OPTIONS.get(request.gpu_type)
|
| 131 |
+
if not gpu_info:
|
| 132 |
+
raise HTTPException(400, f"Unknown GPU type: {request.gpu_type}")
|
| 133 |
+
|
| 134 |
+
_pod_state["status"] = "starting"
|
| 135 |
+
_pod_state["gpu_type"] = request.gpu_type
|
| 136 |
+
_pod_state["cost_per_hour"] = gpu_info["cost"]
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
logger.info("Starting RunPod with %s...", request.gpu_type)
|
| 140 |
+
|
| 141 |
+
pod = runpod.create_pod(
|
| 142 |
+
name="content-engine-gpu",
|
| 143 |
+
image_name=COMFYUI_IMAGE,
|
| 144 |
+
gpu_type_id=request.gpu_type,
|
| 145 |
+
volume_in_gb=50, # For models and LoRAs
|
| 146 |
+
container_disk_in_gb=20,
|
| 147 |
+
ports="8188/http",
|
| 148 |
+
env={
|
| 149 |
+
# Pre-load FLUX model
|
| 150 |
+
"MODEL_URL": "https://huggingface.co/black-forest-labs/FLUX.1-dev/resolve/main/flux1-dev.safetensors",
|
| 151 |
+
},
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
_pod_state["pod_id"] = pod["id"]
|
| 155 |
+
_pod_state["started_at"] = time.time()
|
| 156 |
+
|
| 157 |
+
logger.info("Pod created: %s", pod["id"])
|
| 158 |
+
|
| 159 |
+
# Start background task to wait for pod ready
|
| 160 |
+
asyncio.create_task(_wait_for_pod_ready(pod["id"]))
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
"status": "starting",
|
| 164 |
+
"pod_id": pod["id"],
|
| 165 |
+
"message": f"Starting {gpu_info['name']} pod (~2-3 min)",
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
_pod_state["status"] = "stopped"
|
| 170 |
+
logger.error("Failed to start pod: %s", e)
|
| 171 |
+
raise HTTPException(500, f"Failed to start pod: {e}")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
async def _wait_for_pod_ready(pod_id: str, timeout: int = 300):
|
| 175 |
+
"""Background task to wait for pod to be ready."""
|
| 176 |
+
start = time.time()
|
| 177 |
+
|
| 178 |
+
while time.time() - start < timeout:
|
| 179 |
+
try:
|
| 180 |
+
pod = runpod.get_pod(pod_id)
|
| 181 |
+
|
| 182 |
+
if pod and pod.get("desiredStatus") == "RUNNING":
|
| 183 |
+
runtime = pod.get("runtime", {})
|
| 184 |
+
ports = runtime.get("ports", [])
|
| 185 |
+
|
| 186 |
+
for p in ports:
|
| 187 |
+
if p.get("privatePort") == 8188:
|
| 188 |
+
ip = p.get("ip")
|
| 189 |
+
port = p.get("publicPort")
|
| 190 |
+
|
| 191 |
+
if ip and port:
|
| 192 |
+
_pod_state["ip"] = ip
|
| 193 |
+
_pod_state["port"] = int(port)
|
| 194 |
+
_pod_state["status"] = "running"
|
| 195 |
+
logger.info("Pod ready at %s:%s", ip, port)
|
| 196 |
+
return
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.debug("Waiting for pod: %s", e)
|
| 200 |
+
|
| 201 |
+
await asyncio.sleep(5)
|
| 202 |
+
|
| 203 |
+
logger.error("Pod did not become ready within %ds", timeout)
|
| 204 |
+
_pod_state["status"] = "stopped"
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@router.post("/stop")
|
| 208 |
+
async def stop_pod():
|
| 209 |
+
"""Stop the GPU pod."""
|
| 210 |
+
_get_api_key()
|
| 211 |
+
|
| 212 |
+
if not _pod_state["pod_id"]:
|
| 213 |
+
return {"status": "already_stopped"}
|
| 214 |
+
|
| 215 |
+
if _pod_state["status"] == "stopping":
|
| 216 |
+
return {"status": "stopping", "message": "Pod is already stopping"}
|
| 217 |
+
|
| 218 |
+
_pod_state["status"] = "stopping"
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
pod_id = _pod_state["pod_id"]
|
| 222 |
+
logger.info("Stopping pod: %s", pod_id)
|
| 223 |
+
|
| 224 |
+
runpod.terminate_pod(pod_id)
|
| 225 |
+
|
| 226 |
+
_pod_state["pod_id"] = None
|
| 227 |
+
_pod_state["ip"] = None
|
| 228 |
+
_pod_state["port"] = None
|
| 229 |
+
_pod_state["status"] = "stopped"
|
| 230 |
+
_pod_state["started_at"] = None
|
| 231 |
+
|
| 232 |
+
logger.info("Pod stopped")
|
| 233 |
+
return {"status": "stopped", "message": "Pod terminated"}
|
| 234 |
+
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error("Failed to stop pod: %s", e)
|
| 237 |
+
_pod_state["status"] = "running" # Revert
|
| 238 |
+
raise HTTPException(500, f"Failed to stop pod: {e}")
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@router.get("/loras")
|
| 242 |
+
async def list_pod_loras():
|
| 243 |
+
"""List LoRAs available on the pod."""
|
| 244 |
+
if _pod_state["status"] != "running" or not _pod_state["ip"]:
|
| 245 |
+
return {"loras": [], "message": "Pod not running"}
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
import httpx
|
| 249 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 250 |
+
url = f"http://{_pod_state['ip']}:{_pod_state['port']}/object_info/LoraLoader"
|
| 251 |
+
resp = await client.get(url)
|
| 252 |
+
if resp.status_code == 200:
|
| 253 |
+
data = resp.json()
|
| 254 |
+
loras = data.get("LoraLoader", {}).get("input", {}).get("required", {}).get("lora_name", [[]])[0]
|
| 255 |
+
return {"loras": loras if isinstance(loras, list) else []}
|
| 256 |
+
except Exception as e:
|
| 257 |
+
logger.warning("Failed to list pod LoRAs: %s", e)
|
| 258 |
+
|
| 259 |
+
return {"loras": [], "comfyui_url": f"http://{_pod_state['ip']}:{_pod_state['port']}"}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@router.post("/upload-lora")
|
| 263 |
+
async def upload_lora_to_pod(
|
| 264 |
+
file: UploadFile = File(...),
|
| 265 |
+
):
|
| 266 |
+
"""Upload a LoRA file to the running pod."""
|
| 267 |
+
from fastapi import UploadFile, File
|
| 268 |
+
import httpx
|
| 269 |
+
|
| 270 |
+
if _pod_state["status"] != "running":
|
| 271 |
+
raise HTTPException(400, "Pod not running - start it first")
|
| 272 |
+
|
| 273 |
+
if not file.filename.endswith(".safetensors"):
|
| 274 |
+
raise HTTPException(400, "Only .safetensors files supported")
|
| 275 |
+
|
| 276 |
+
try:
|
| 277 |
+
content = await file.read()
|
| 278 |
+
|
| 279 |
+
async with httpx.AsyncClient(timeout=120) as client:
|
| 280 |
+
# Upload to ComfyUI's models/loras directory
|
| 281 |
+
url = f"http://{_pod_state['ip']}:{_pod_state['port']}/upload/image"
|
| 282 |
+
files = {"image": (file.filename, content, "application/octet-stream")}
|
| 283 |
+
data = {"subfolder": "loras", "type": "input"}
|
| 284 |
+
|
| 285 |
+
resp = await client.post(url, files=files, data=data)
|
| 286 |
+
|
| 287 |
+
if resp.status_code == 200:
|
| 288 |
+
return {"status": "uploaded", "filename": file.filename}
|
| 289 |
+
else:
|
| 290 |
+
raise HTTPException(500, f"Upload failed: {resp.text}")
|
| 291 |
+
|
| 292 |
+
except httpx.TimeoutException:
|
| 293 |
+
raise HTTPException(504, "Upload timed out")
|
| 294 |
+
except Exception as e:
|
| 295 |
+
raise HTTPException(500, f"Upload failed: {e}")
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
class PodGenerateRequest(BaseModel):
|
| 299 |
+
prompt: str
|
| 300 |
+
negative_prompt: str = ""
|
| 301 |
+
width: int = 1024
|
| 302 |
+
height: int = 1024
|
| 303 |
+
steps: int = 28
|
| 304 |
+
cfg: float = 3.5
|
| 305 |
+
seed: int = -1
|
| 306 |
+
lora_name: str | None = None
|
| 307 |
+
lora_strength: float = 0.85
|
| 308 |
+
character_id: str | None = None
|
| 309 |
+
template_id: str | None = None
|
| 310 |
+
content_rating: str = "sfw"
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
# In-memory job tracking for pod generation
|
| 314 |
+
_pod_jobs: dict[str, dict] = {}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
@router.post("/generate")
|
| 318 |
+
async def generate_on_pod(request: PodGenerateRequest):
|
| 319 |
+
"""Generate an image using the running pod's ComfyUI."""
|
| 320 |
+
import httpx
|
| 321 |
+
import random
|
| 322 |
+
|
| 323 |
+
if _pod_state["status"] != "running":
|
| 324 |
+
raise HTTPException(400, "Pod not running - start it first")
|
| 325 |
+
|
| 326 |
+
job_id = str(uuid.uuid4())[:8]
|
| 327 |
+
seed = request.seed if request.seed >= 0 else random.randint(0, 2**32 - 1)
|
| 328 |
+
|
| 329 |
+
# Build ComfyUI workflow
|
| 330 |
+
workflow = _build_flux_workflow(
|
| 331 |
+
prompt=request.prompt,
|
| 332 |
+
negative_prompt=request.negative_prompt,
|
| 333 |
+
width=request.width,
|
| 334 |
+
height=request.height,
|
| 335 |
+
steps=request.steps,
|
| 336 |
+
cfg=request.cfg,
|
| 337 |
+
seed=seed,
|
| 338 |
+
lora_name=request.lora_name,
|
| 339 |
+
lora_strength=request.lora_strength,
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
try:
|
| 343 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 344 |
+
url = f"http://{_pod_state['ip']}:{_pod_state['port']}/prompt"
|
| 345 |
+
resp = await client.post(url, json={"prompt": workflow})
|
| 346 |
+
resp.raise_for_status()
|
| 347 |
+
|
| 348 |
+
data = resp.json()
|
| 349 |
+
prompt_id = data["prompt_id"]
|
| 350 |
+
|
| 351 |
+
_pod_jobs[job_id] = {
|
| 352 |
+
"prompt_id": prompt_id,
|
| 353 |
+
"status": "running",
|
| 354 |
+
"seed": seed,
|
| 355 |
+
"started_at": time.time(),
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
logger.info("Pod generation started: %s -> %s", job_id, prompt_id)
|
| 359 |
+
|
| 360 |
+
# Start background task to poll for completion
|
| 361 |
+
asyncio.create_task(_poll_pod_job(job_id, prompt_id, request.content_rating))
|
| 362 |
+
|
| 363 |
+
return {
|
| 364 |
+
"job_id": job_id,
|
| 365 |
+
"status": "running",
|
| 366 |
+
"seed": seed,
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
except Exception as e:
|
| 370 |
+
logger.error("Pod generation failed: %s", e)
|
| 371 |
+
raise HTTPException(500, f"Generation failed: {e}")
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
async def _poll_pod_job(job_id: str, prompt_id: str, content_rating: str):
|
| 375 |
+
"""Poll ComfyUI for job completion and save the result."""
|
| 376 |
+
import httpx
|
| 377 |
+
from pathlib import Path
|
| 378 |
+
|
| 379 |
+
start = time.time()
|
| 380 |
+
timeout = 300 # 5 minutes
|
| 381 |
+
|
| 382 |
+
async with httpx.AsyncClient(timeout=60) as client:
|
| 383 |
+
while time.time() - start < timeout:
|
| 384 |
+
try:
|
| 385 |
+
url = f"http://{_pod_state['ip']}:{_pod_state['port']}/history/{prompt_id}"
|
| 386 |
+
resp = await client.get(url)
|
| 387 |
+
|
| 388 |
+
if resp.status_code == 200:
|
| 389 |
+
data = resp.json()
|
| 390 |
+
if prompt_id in data:
|
| 391 |
+
outputs = data[prompt_id].get("outputs", {})
|
| 392 |
+
|
| 393 |
+
# Find SaveImage output
|
| 394 |
+
for node_id, node_output in outputs.items():
|
| 395 |
+
if "images" in node_output:
|
| 396 |
+
image_info = node_output["images"][0]
|
| 397 |
+
filename = image_info["filename"]
|
| 398 |
+
subfolder = image_info.get("subfolder", "")
|
| 399 |
+
|
| 400 |
+
# Download the image
|
| 401 |
+
img_url = f"http://{_pod_state['ip']}:{_pod_state['port']}/view"
|
| 402 |
+
params = {"filename": filename}
|
| 403 |
+
if subfolder:
|
| 404 |
+
params["subfolder"] = subfolder
|
| 405 |
+
|
| 406 |
+
img_resp = await client.get(img_url, params=params)
|
| 407 |
+
if img_resp.status_code == 200:
|
| 408 |
+
# Save to local output directory
|
| 409 |
+
from content_engine.config import settings
|
| 410 |
+
output_dir = settings.paths.output_dir / "pod" / content_rating / "raw"
|
| 411 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 412 |
+
|
| 413 |
+
local_path = output_dir / f"pod_{job_id}.png"
|
| 414 |
+
local_path.write_bytes(img_resp.content)
|
| 415 |
+
|
| 416 |
+
_pod_jobs[job_id]["status"] = "completed"
|
| 417 |
+
_pod_jobs[job_id]["output_path"] = str(local_path)
|
| 418 |
+
_pod_jobs[job_id]["completed_at"] = time.time()
|
| 419 |
+
|
| 420 |
+
logger.info("Pod generation completed: %s -> %s", job_id, local_path)
|
| 421 |
+
|
| 422 |
+
# Catalog the image
|
| 423 |
+
try:
|
| 424 |
+
from content_engine.services.catalog import CatalogService
|
| 425 |
+
catalog = CatalogService()
|
| 426 |
+
await catalog.add_image(
|
| 427 |
+
image_path=local_path,
|
| 428 |
+
content_rating=content_rating,
|
| 429 |
+
seed=_pod_jobs[job_id].get("seed"),
|
| 430 |
+
backend="runpod-pod",
|
| 431 |
+
)
|
| 432 |
+
except Exception as e:
|
| 433 |
+
logger.warning("Failed to catalog pod image: %s", e)
|
| 434 |
+
|
| 435 |
+
return
|
| 436 |
+
|
| 437 |
+
except Exception as e:
|
| 438 |
+
logger.debug("Polling pod job: %s", e)
|
| 439 |
+
|
| 440 |
+
await asyncio.sleep(2)
|
| 441 |
+
|
| 442 |
+
_pod_jobs[job_id]["status"] = "failed"
|
| 443 |
+
_pod_jobs[job_id]["error"] = "Timeout waiting for generation"
|
| 444 |
+
logger.error("Pod generation timed out: %s", job_id)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
@router.get("/jobs/{job_id}")
|
| 448 |
+
async def get_pod_job(job_id: str):
|
| 449 |
+
"""Get status of a pod generation job."""
|
| 450 |
+
job = _pod_jobs.get(job_id)
|
| 451 |
+
if not job:
|
| 452 |
+
raise HTTPException(404, "Job not found")
|
| 453 |
+
return job
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def _build_flux_workflow(
|
| 457 |
+
prompt: str,
|
| 458 |
+
negative_prompt: str,
|
| 459 |
+
width: int,
|
| 460 |
+
height: int,
|
| 461 |
+
steps: int,
|
| 462 |
+
cfg: float,
|
| 463 |
+
seed: int,
|
| 464 |
+
lora_name: str | None,
|
| 465 |
+
lora_strength: float,
|
| 466 |
+
) -> dict:
|
| 467 |
+
"""Build a ComfyUI workflow for FLUX generation."""
|
| 468 |
+
|
| 469 |
+
# Basic FLUX workflow - compatible with ComfyUI FLUX setup
|
| 470 |
+
workflow = {
|
| 471 |
+
"4": {
|
| 472 |
+
"class_type": "CheckpointLoaderSimple",
|
| 473 |
+
"inputs": {"ckpt_name": "flux1-dev.safetensors"},
|
| 474 |
+
},
|
| 475 |
+
"6": {
|
| 476 |
+
"class_type": "CLIPTextEncode",
|
| 477 |
+
"inputs": {
|
| 478 |
+
"text": prompt,
|
| 479 |
+
"clip": ["4", 1],
|
| 480 |
+
},
|
| 481 |
+
},
|
| 482 |
+
"7": {
|
| 483 |
+
"class_type": "CLIPTextEncode",
|
| 484 |
+
"inputs": {
|
| 485 |
+
"text": negative_prompt or "",
|
| 486 |
+
"clip": ["4", 1],
|
| 487 |
+
},
|
| 488 |
+
},
|
| 489 |
+
"5": {
|
| 490 |
+
"class_type": "EmptyLatentImage",
|
| 491 |
+
"inputs": {
|
| 492 |
+
"width": width,
|
| 493 |
+
"height": height,
|
| 494 |
+
"batch_size": 1,
|
| 495 |
+
},
|
| 496 |
+
},
|
| 497 |
+
"3": {
|
| 498 |
+
"class_type": "KSampler",
|
| 499 |
+
"inputs": {
|
| 500 |
+
"seed": seed,
|
| 501 |
+
"steps": steps,
|
| 502 |
+
"cfg": cfg,
|
| 503 |
+
"sampler_name": "euler",
|
| 504 |
+
"scheduler": "simple",
|
| 505 |
+
"denoise": 1.0,
|
| 506 |
+
"model": ["4", 0],
|
| 507 |
+
"positive": ["6", 0],
|
| 508 |
+
"negative": ["7", 0],
|
| 509 |
+
"latent_image": ["5", 0],
|
| 510 |
+
},
|
| 511 |
+
},
|
| 512 |
+
"8": {
|
| 513 |
+
"class_type": "VAEDecode",
|
| 514 |
+
"inputs": {
|
| 515 |
+
"samples": ["3", 0],
|
| 516 |
+
"vae": ["4", 2],
|
| 517 |
+
},
|
| 518 |
+
},
|
| 519 |
+
"9": {
|
| 520 |
+
"class_type": "SaveImage",
|
| 521 |
+
"inputs": {
|
| 522 |
+
"filename_prefix": "flux_pod",
|
| 523 |
+
"images": ["8", 0],
|
| 524 |
+
},
|
| 525 |
+
},
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
# Add LoRA if specified
|
| 529 |
+
if lora_name:
|
| 530 |
+
workflow["10"] = {
|
| 531 |
+
"class_type": "LoraLoader",
|
| 532 |
+
"inputs": {
|
| 533 |
+
"lora_name": lora_name,
|
| 534 |
+
"strength_model": lora_strength,
|
| 535 |
+
"strength_clip": lora_strength,
|
| 536 |
+
"model": ["4", 0],
|
| 537 |
+
"clip": ["4", 1],
|
| 538 |
+
},
|
| 539 |
+
}
|
| 540 |
+
# Rewire sampler to use LoRA output
|
| 541 |
+
workflow["3"]["inputs"]["model"] = ["10", 0]
|
| 542 |
+
workflow["6"]["inputs"]["clip"] = ["10", 1]
|
| 543 |
+
workflow["7"]["inputs"]["clip"] = ["10", 1]
|
| 544 |
+
|
| 545 |
+
return workflow
|
src/content_engine/api/routes_system.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System API routes — health checks, status, and configuration."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
|
| 11 |
+
from content_engine.models.schemas import SystemStatus
|
| 12 |
+
from content_engine.config import IS_HF_SPACES
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api", tags=["system"])
|
| 15 |
+
|
| 16 |
+
_comfyui_client = None
|
| 17 |
+
_catalog = None
|
| 18 |
+
_template_engine = None
|
| 19 |
+
_character_profiles = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def init_routes(comfyui_client, catalog, template_engine, character_profiles=None):
|
| 23 |
+
"""Initialize route dependencies."""
|
| 24 |
+
global _comfyui_client, _catalog, _template_engine, _character_profiles
|
| 25 |
+
_comfyui_client = comfyui_client
|
| 26 |
+
_catalog = catalog
|
| 27 |
+
_template_engine = template_engine
|
| 28 |
+
_character_profiles = character_profiles
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/health")
|
| 32 |
+
async def health_check():
|
| 33 |
+
"""Basic health check."""
|
| 34 |
+
comfyui_ok = False
|
| 35 |
+
if _comfyui_client:
|
| 36 |
+
comfyui_ok = await _comfyui_client.is_available()
|
| 37 |
+
return {"status": "ok", "comfyui": comfyui_ok}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@router.get("/status", response_model=SystemStatus)
|
| 41 |
+
async def system_status():
|
| 42 |
+
"""Get comprehensive system status."""
|
| 43 |
+
comfyui_connected = False
|
| 44 |
+
gpu_name = None
|
| 45 |
+
vram_total_gb = None
|
| 46 |
+
vram_free_gb = None
|
| 47 |
+
queue_depth = 0
|
| 48 |
+
|
| 49 |
+
if _comfyui_client:
|
| 50 |
+
comfyui_connected = await _comfyui_client.is_available()
|
| 51 |
+
if comfyui_connected:
|
| 52 |
+
try:
|
| 53 |
+
stats = await _comfyui_client.get_system_stats()
|
| 54 |
+
devices = stats.get("devices", [])
|
| 55 |
+
if devices:
|
| 56 |
+
gpu_name = devices[0].get("name")
|
| 57 |
+
vram_total_gb = devices[0].get("vram_total", 0) / (1024**3)
|
| 58 |
+
vram_free_gb = devices[0].get("vram_free", 0) / (1024**3)
|
| 59 |
+
queue_depth = await _comfyui_client.get_queue_depth()
|
| 60 |
+
except Exception:
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
total_images = 0
|
| 64 |
+
if _catalog:
|
| 65 |
+
total_images = await _catalog.get_total_count()
|
| 66 |
+
|
| 67 |
+
return SystemStatus(
|
| 68 |
+
comfyui_connected=comfyui_connected,
|
| 69 |
+
gpu_name=gpu_name,
|
| 70 |
+
vram_total_gb=round(vram_total_gb, 2) if vram_total_gb else None,
|
| 71 |
+
vram_free_gb=round(vram_free_gb, 2) if vram_free_gb else None,
|
| 72 |
+
local_queue_depth=queue_depth,
|
| 73 |
+
cloud_available=False, # Phase 4
|
| 74 |
+
total_images=total_images,
|
| 75 |
+
pending_jobs=0,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/templates")
|
| 80 |
+
async def list_templates():
|
| 81 |
+
"""List all available prompt templates."""
|
| 82 |
+
if _template_engine is None:
|
| 83 |
+
return []
|
| 84 |
+
templates = _template_engine.list_templates()
|
| 85 |
+
return [
|
| 86 |
+
{
|
| 87 |
+
"id": t.id,
|
| 88 |
+
"name": t.name,
|
| 89 |
+
"category": t.category,
|
| 90 |
+
"rating": t.rating,
|
| 91 |
+
"variables": {
|
| 92 |
+
name: {
|
| 93 |
+
"type": vdef.type,
|
| 94 |
+
"options": vdef.options,
|
| 95 |
+
"required": vdef.required,
|
| 96 |
+
}
|
| 97 |
+
for name, vdef in t.variables.items()
|
| 98 |
+
},
|
| 99 |
+
}
|
| 100 |
+
for t in templates
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@router.get("/characters")
|
| 105 |
+
async def list_characters():
|
| 106 |
+
"""List all configured character profiles."""
|
| 107 |
+
if _character_profiles is None:
|
| 108 |
+
return []
|
| 109 |
+
return [
|
| 110 |
+
{
|
| 111 |
+
"id": c.id,
|
| 112 |
+
"name": c.name,
|
| 113 |
+
"trigger_word": c.trigger_word,
|
| 114 |
+
"lora_filename": c.lora_filename,
|
| 115 |
+
"lora_strength": c.lora_strength,
|
| 116 |
+
"description": c.description,
|
| 117 |
+
}
|
| 118 |
+
for c in _character_profiles.values()
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.get("/models/loras")
|
| 123 |
+
async def list_loras():
|
| 124 |
+
"""List available LoRA models from ComfyUI."""
|
| 125 |
+
if _comfyui_client is None:
|
| 126 |
+
return []
|
| 127 |
+
try:
|
| 128 |
+
return await _comfyui_client.get_models("loras")
|
| 129 |
+
except Exception:
|
| 130 |
+
return []
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@router.get("/models/checkpoints")
|
| 134 |
+
async def list_checkpoints():
|
| 135 |
+
"""List available checkpoint models from ComfyUI."""
|
| 136 |
+
if _comfyui_client is None:
|
| 137 |
+
return []
|
| 138 |
+
try:
|
| 139 |
+
return await _comfyui_client.get_models("checkpoints")
|
| 140 |
+
except Exception:
|
| 141 |
+
return []
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# --- API Settings ---
|
| 145 |
+
|
| 146 |
+
class APISettingsResponse(BaseModel):
|
| 147 |
+
runpod_configured: bool
|
| 148 |
+
runpod_key_preview: str | None = None
|
| 149 |
+
wavespeed_configured: bool
|
| 150 |
+
wavespeed_key_preview: str | None = None
|
| 151 |
+
is_cloud: bool
|
| 152 |
+
env_file_path: str | None = None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class UpdateAPIKeysRequest(BaseModel):
|
| 156 |
+
runpod_api_key: str | None = None
|
| 157 |
+
wavespeed_api_key: str | None = None
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _mask_key(key: str | None) -> str | None:
|
| 161 |
+
"""Mask API key showing only last 4 chars."""
|
| 162 |
+
if not key:
|
| 163 |
+
return None
|
| 164 |
+
if len(key) <= 8:
|
| 165 |
+
return "****"
|
| 166 |
+
return f"****{key[-4:]}"
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@router.get("/settings/api", response_model=APISettingsResponse)
|
| 170 |
+
async def get_api_settings():
|
| 171 |
+
"""Get current API settings status (keys are masked)."""
|
| 172 |
+
runpod_key = os.environ.get("RUNPOD_API_KEY")
|
| 173 |
+
wavespeed_key = os.environ.get("WAVESPEED_API_KEY")
|
| 174 |
+
|
| 175 |
+
env_file = None
|
| 176 |
+
if not IS_HF_SPACES:
|
| 177 |
+
env_file = "D:/AI automation/content_engine/.env"
|
| 178 |
+
|
| 179 |
+
return APISettingsResponse(
|
| 180 |
+
runpod_configured=bool(runpod_key),
|
| 181 |
+
runpod_key_preview=_mask_key(runpod_key),
|
| 182 |
+
wavespeed_configured=bool(wavespeed_key),
|
| 183 |
+
wavespeed_key_preview=_mask_key(wavespeed_key),
|
| 184 |
+
is_cloud=IS_HF_SPACES,
|
| 185 |
+
env_file_path=env_file,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.post("/settings/api")
|
| 190 |
+
async def update_api_settings(request: UpdateAPIKeysRequest):
|
| 191 |
+
"""Update API keys. Only works in local mode (not HF Spaces).
|
| 192 |
+
|
| 193 |
+
On HF Spaces, use the Settings > Secrets panel instead.
|
| 194 |
+
"""
|
| 195 |
+
if IS_HF_SPACES:
|
| 196 |
+
raise HTTPException(
|
| 197 |
+
400,
|
| 198 |
+
"Cannot update API keys on Hugging Face Spaces. "
|
| 199 |
+
"Use Settings > Variables and secrets in your Space dashboard."
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
env_path = Path("D:/AI automation/content_engine/.env")
|
| 203 |
+
|
| 204 |
+
# Read existing .env
|
| 205 |
+
existing = {}
|
| 206 |
+
if env_path.exists():
|
| 207 |
+
with open(env_path) as f:
|
| 208 |
+
for line in f:
|
| 209 |
+
line = line.strip()
|
| 210 |
+
if line and not line.startswith("#") and "=" in line:
|
| 211 |
+
key, val = line.split("=", 1)
|
| 212 |
+
existing[key.strip()] = val.strip()
|
| 213 |
+
|
| 214 |
+
# Update keys
|
| 215 |
+
updated = []
|
| 216 |
+
if request.runpod_api_key is not None:
|
| 217 |
+
existing["RUNPOD_API_KEY"] = request.runpod_api_key
|
| 218 |
+
os.environ["RUNPOD_API_KEY"] = request.runpod_api_key
|
| 219 |
+
updated.append("RUNPOD_API_KEY")
|
| 220 |
+
|
| 221 |
+
if request.wavespeed_api_key is not None:
|
| 222 |
+
existing["WAVESPEED_API_KEY"] = request.wavespeed_api_key
|
| 223 |
+
os.environ["WAVESPEED_API_KEY"] = request.wavespeed_api_key
|
| 224 |
+
updated.append("WAVESPEED_API_KEY")
|
| 225 |
+
|
| 226 |
+
# Write back
|
| 227 |
+
with open(env_path, "w") as f:
|
| 228 |
+
for key, val in existing.items():
|
| 229 |
+
f.write(f"{key}={val}\n")
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
"status": "updated",
|
| 233 |
+
"updated_keys": updated,
|
| 234 |
+
"message": "API keys updated. Restart the server to fully apply changes."
|
| 235 |
+
}
|
src/content_engine/api/routes_training.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training API routes — LoRA model training management."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
| 9 |
+
|
| 10 |
+
from content_engine.services.lora_trainer import LoRATrainer, TrainingConfig
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/training", tags=["training"])
|
| 15 |
+
|
| 16 |
+
_trainer: LoRATrainer | None = None
|
| 17 |
+
_runpod_trainer = None # RunPodTrainer | None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def init_routes(trainer: LoRATrainer, runpod_trainer=None):
|
| 21 |
+
global _trainer, _runpod_trainer
|
| 22 |
+
_trainer = trainer
|
| 23 |
+
_runpod_trainer = runpod_trainer
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.get("/status")
|
| 27 |
+
async def training_status():
|
| 28 |
+
"""Check if training infrastructure is ready."""
|
| 29 |
+
if _trainer is None:
|
| 30 |
+
return {"ready": False, "sd_scripts_installed": False, "runpod_available": False}
|
| 31 |
+
return {
|
| 32 |
+
"ready": True,
|
| 33 |
+
"sd_scripts_installed": _trainer.sd_scripts_installed,
|
| 34 |
+
"runpod_available": _runpod_trainer is not None and _runpod_trainer.available,
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@router.get("/models")
|
| 39 |
+
async def list_training_models():
|
| 40 |
+
"""List available base models for LoRA training with their recommended parameters."""
|
| 41 |
+
if _runpod_trainer is None:
|
| 42 |
+
return {"models": {}, "default": "flux2_dev"}
|
| 43 |
+
|
| 44 |
+
models = _runpod_trainer.list_training_models()
|
| 45 |
+
return {
|
| 46 |
+
"models": models,
|
| 47 |
+
"default": "flux2_dev", # FLUX 2 recommended for realistic person
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.get("/gpu-options")
|
| 52 |
+
async def list_gpu_options():
|
| 53 |
+
"""List available RunPod GPU types."""
|
| 54 |
+
if _runpod_trainer is None:
|
| 55 |
+
return {"gpus": {}}
|
| 56 |
+
return {"gpus": _runpod_trainer.list_gpu_options()}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@router.post("/install")
|
| 60 |
+
async def install_sd_scripts():
|
| 61 |
+
"""Install Kohya sd-scripts for LoRA training."""
|
| 62 |
+
if _trainer is None:
|
| 63 |
+
raise HTTPException(503, "Trainer not initialized")
|
| 64 |
+
try:
|
| 65 |
+
msg = await _trainer.install_sd_scripts()
|
| 66 |
+
return {"status": "ok", "message": msg}
|
| 67 |
+
except Exception as e:
|
| 68 |
+
raise HTTPException(500, f"Installation failed: {e}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.post("/start")
|
| 72 |
+
async def start_training(
|
| 73 |
+
images: list[UploadFile] = File(...),
|
| 74 |
+
name: str = Form(...),
|
| 75 |
+
trigger_word: str = Form(""),
|
| 76 |
+
captions_json: str = Form("{}"),
|
| 77 |
+
base_model: str = Form("flux2_dev"), # Model registry key (flux2_dev, sd15_realistic, sdxl_base)
|
| 78 |
+
resolution: int | None = Form(None), # None = use model default
|
| 79 |
+
num_epochs: int = Form(10),
|
| 80 |
+
max_train_steps: int | None = Form(None), # If set, overrides epochs
|
| 81 |
+
learning_rate: float | None = Form(None), # None = use model default
|
| 82 |
+
network_rank: int | None = Form(None), # None = use model default
|
| 83 |
+
network_alpha: int | None = Form(None), # None = use model default
|
| 84 |
+
optimizer: str | None = Form(None), # None = use model default
|
| 85 |
+
train_batch_size: int = Form(1),
|
| 86 |
+
save_every_n_epochs: int = Form(2),
|
| 87 |
+
backend: str = Form("runpod"), # Default to runpod for cloud training
|
| 88 |
+
gpu_type: str = Form("NVIDIA GeForce RTX 4090"),
|
| 89 |
+
):
|
| 90 |
+
"""Start a LoRA training job (local or RunPod cloud).
|
| 91 |
+
|
| 92 |
+
Parameters like resolution, learning_rate, network_rank will use model
|
| 93 |
+
registry defaults if not specified. Use base_model to select the model type.
|
| 94 |
+
"""
|
| 95 |
+
import json
|
| 96 |
+
|
| 97 |
+
if len(images) < 5:
|
| 98 |
+
raise HTTPException(400, "Need at least 5 training images for reasonable results")
|
| 99 |
+
|
| 100 |
+
# Parse captions
|
| 101 |
+
try:
|
| 102 |
+
captions = json.loads(captions_json) if captions_json else {}
|
| 103 |
+
except json.JSONDecodeError:
|
| 104 |
+
captions = {}
|
| 105 |
+
|
| 106 |
+
# Save uploaded images to temp directory
|
| 107 |
+
import uuid
|
| 108 |
+
from content_engine.config import settings
|
| 109 |
+
upload_dir = settings.paths.data_dir / "training_uploads" / str(uuid.uuid4())[:8]
|
| 110 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 111 |
+
|
| 112 |
+
image_paths = []
|
| 113 |
+
for img in images:
|
| 114 |
+
file_path = upload_dir / img.filename
|
| 115 |
+
content = await img.read()
|
| 116 |
+
file_path.write_bytes(content)
|
| 117 |
+
image_paths.append(str(file_path))
|
| 118 |
+
|
| 119 |
+
# Write caption .txt file alongside the image
|
| 120 |
+
caption_text = captions.get(img.filename, trigger_word or "")
|
| 121 |
+
caption_path = file_path.with_suffix(".txt")
|
| 122 |
+
caption_path.write_text(caption_text, encoding="utf-8")
|
| 123 |
+
logger.info("Saved caption for %s: %s", img.filename, caption_text[:80])
|
| 124 |
+
|
| 125 |
+
# Route to RunPod cloud trainer
|
| 126 |
+
if backend == "runpod":
|
| 127 |
+
if _runpod_trainer is None:
|
| 128 |
+
raise HTTPException(503, "RunPod not configured — set RUNPOD_API_KEY in .env")
|
| 129 |
+
|
| 130 |
+
# Validate model exists
|
| 131 |
+
model_cfg = _runpod_trainer.get_model_config(base_model)
|
| 132 |
+
if not model_cfg:
|
| 133 |
+
available = list(_runpod_trainer.list_training_models().keys())
|
| 134 |
+
raise HTTPException(400, f"Unknown base model: {base_model}. Available: {available}")
|
| 135 |
+
|
| 136 |
+
job_id = await _runpod_trainer.start_training(
|
| 137 |
+
name=name,
|
| 138 |
+
image_paths=image_paths,
|
| 139 |
+
trigger_word=trigger_word,
|
| 140 |
+
base_model=base_model,
|
| 141 |
+
resolution=resolution,
|
| 142 |
+
num_epochs=num_epochs,
|
| 143 |
+
max_train_steps=max_train_steps,
|
| 144 |
+
learning_rate=learning_rate,
|
| 145 |
+
network_rank=network_rank,
|
| 146 |
+
network_alpha=network_alpha,
|
| 147 |
+
optimizer=optimizer,
|
| 148 |
+
save_every_n_epochs=save_every_n_epochs,
|
| 149 |
+
gpu_type=gpu_type,
|
| 150 |
+
)
|
| 151 |
+
job = _runpod_trainer.get_job(job_id)
|
| 152 |
+
return {
|
| 153 |
+
"job_id": job_id,
|
| 154 |
+
"status": job.status if job else "unknown",
|
| 155 |
+
"name": name,
|
| 156 |
+
"backend": "runpod",
|
| 157 |
+
"base_model": base_model,
|
| 158 |
+
"model_type": model_cfg.get("model_type", "unknown"),
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
# Local training (uses local GPU with Kohya sd-scripts)
|
| 162 |
+
if _trainer is None:
|
| 163 |
+
raise HTTPException(503, "Trainer not initialized")
|
| 164 |
+
|
| 165 |
+
# For local training, use model registry defaults if available
|
| 166 |
+
model_cfg = {}
|
| 167 |
+
if _runpod_trainer:
|
| 168 |
+
model_cfg = _runpod_trainer.get_model_config(base_model) or {}
|
| 169 |
+
|
| 170 |
+
# Resolve local model path
|
| 171 |
+
local_model_path = model_cfg.get("local_path") if model_cfg else None
|
| 172 |
+
if not local_model_path:
|
| 173 |
+
# Fall back to default local path
|
| 174 |
+
local_model_path = str(settings.paths.checkpoint_dir / "realisticVisionV51_v51VAE.safetensors")
|
| 175 |
+
|
| 176 |
+
config = TrainingConfig(
|
| 177 |
+
name=name,
|
| 178 |
+
trigger_word=trigger_word,
|
| 179 |
+
base_model=local_model_path,
|
| 180 |
+
resolution=resolution or model_cfg.get("resolution", 512),
|
| 181 |
+
num_epochs=num_epochs,
|
| 182 |
+
learning_rate=learning_rate or model_cfg.get("learning_rate", 1e-4),
|
| 183 |
+
network_rank=network_rank or model_cfg.get("network_rank", 32),
|
| 184 |
+
network_alpha=network_alpha or model_cfg.get("network_alpha", 16),
|
| 185 |
+
optimizer=optimizer or model_cfg.get("optimizer", "AdamW8bit"),
|
| 186 |
+
train_batch_size=train_batch_size,
|
| 187 |
+
save_every_n_epochs=save_every_n_epochs,
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
job_id = await _trainer.start_training(config, image_paths)
|
| 191 |
+
job = _trainer.get_job(job_id)
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
"job_id": job_id,
|
| 195 |
+
"status": job.status if job else "unknown",
|
| 196 |
+
"name": name,
|
| 197 |
+
"backend": "local",
|
| 198 |
+
"base_model": base_model,
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@router.get("/jobs")
|
| 203 |
+
async def list_training_jobs():
|
| 204 |
+
"""List all training jobs (local + cloud)."""
|
| 205 |
+
jobs = []
|
| 206 |
+
if _trainer:
|
| 207 |
+
for j in _trainer.list_jobs():
|
| 208 |
+
jobs.append({
|
| 209 |
+
"id": j.id, "name": j.name, "status": j.status,
|
| 210 |
+
"progress": round(j.progress, 3),
|
| 211 |
+
"current_epoch": j.current_epoch, "total_epochs": j.total_epochs,
|
| 212 |
+
"current_step": j.current_step, "total_steps": j.total_steps,
|
| 213 |
+
"loss": j.loss, "started_at": j.started_at,
|
| 214 |
+
"completed_at": j.completed_at, "output_path": j.output_path,
|
| 215 |
+
"error": j.error, "backend": "local",
|
| 216 |
+
})
|
| 217 |
+
if _runpod_trainer:
|
| 218 |
+
for j in _runpod_trainer.list_jobs():
|
| 219 |
+
jobs.append({
|
| 220 |
+
"id": j.id, "name": j.name, "status": j.status,
|
| 221 |
+
"progress": round(j.progress, 3),
|
| 222 |
+
"current_epoch": j.current_epoch, "total_epochs": j.total_epochs,
|
| 223 |
+
"current_step": j.current_step, "total_steps": j.total_steps,
|
| 224 |
+
"loss": j.loss, "started_at": j.started_at,
|
| 225 |
+
"completed_at": j.completed_at, "output_path": j.output_path,
|
| 226 |
+
"error": j.error, "backend": "runpod",
|
| 227 |
+
"base_model": j.base_model, "model_type": j.model_type,
|
| 228 |
+
})
|
| 229 |
+
return jobs
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.get("/jobs/{job_id}")
|
| 233 |
+
async def get_training_job(job_id: str):
|
| 234 |
+
"""Get details of a specific training job including logs."""
|
| 235 |
+
if _trainer is None:
|
| 236 |
+
raise HTTPException(503, "Trainer not initialized")
|
| 237 |
+
job = _trainer.get_job(job_id)
|
| 238 |
+
if job is None:
|
| 239 |
+
raise HTTPException(404, f"Training job not found: {job_id}")
|
| 240 |
+
return {
|
| 241 |
+
"id": job.id,
|
| 242 |
+
"name": job.name,
|
| 243 |
+
"status": job.status,
|
| 244 |
+
"progress": round(job.progress, 3),
|
| 245 |
+
"current_epoch": job.current_epoch,
|
| 246 |
+
"total_epochs": job.total_epochs,
|
| 247 |
+
"current_step": job.current_step,
|
| 248 |
+
"total_steps": job.total_steps,
|
| 249 |
+
"loss": job.loss,
|
| 250 |
+
"started_at": job.started_at,
|
| 251 |
+
"completed_at": job.completed_at,
|
| 252 |
+
"output_path": job.output_path,
|
| 253 |
+
"error": job.error,
|
| 254 |
+
"log_lines": job.log_lines[-50:],
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@router.post("/jobs/{job_id}/cancel")
|
| 259 |
+
async def cancel_training_job(job_id: str):
|
| 260 |
+
"""Cancel a running training job (local or cloud)."""
|
| 261 |
+
if _runpod_trainer and _runpod_trainer.get_job(job_id):
|
| 262 |
+
cancelled = await _runpod_trainer.cancel_job(job_id)
|
| 263 |
+
if cancelled:
|
| 264 |
+
return {"status": "cancelled", "job_id": job_id}
|
| 265 |
+
if _trainer:
|
| 266 |
+
cancelled = await _trainer.cancel_job(job_id)
|
| 267 |
+
if cancelled:
|
| 268 |
+
return {"status": "cancelled", "job_id": job_id}
|
| 269 |
+
raise HTTPException(404, "Job not found or not running")
|
src/content_engine/api/routes_ui.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Web UI route — serves the single-page dashboard."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
from fastapi.responses import HTMLResponse, Response
|
| 9 |
+
|
| 10 |
+
router = APIRouter(tags=["ui"])
|
| 11 |
+
|
| 12 |
+
UI_HTML_PATH = Path(__file__).parent / "ui.html"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.get("/", response_class=HTMLResponse)
|
| 16 |
+
async def dashboard():
|
| 17 |
+
"""Serve the main dashboard UI."""
|
| 18 |
+
content = UI_HTML_PATH.read_text(encoding="utf-8")
|
| 19 |
+
return Response(
|
| 20 |
+
content=content,
|
| 21 |
+
media_type="text/html",
|
| 22 |
+
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
| 23 |
+
)
|
src/content_engine/api/routes_video.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Video generation routes — WAN 2.2 img2video on RunPod pod."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
import uuid
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import runpod
|
| 13 |
+
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
router = APIRouter(prefix="/api/video", tags=["video"])
|
| 19 |
+
|
| 20 |
+
# Video jobs tracking
|
| 21 |
+
_video_jobs: dict[str, dict] = {}
|
| 22 |
+
|
| 23 |
+
# Pod state is shared from routes_pod
|
| 24 |
+
def _get_pod_state():
|
| 25 |
+
from content_engine.api.routes_pod import _pod_state
|
| 26 |
+
return _pod_state
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class VideoGenerateRequest(BaseModel):
|
| 30 |
+
prompt: str
|
| 31 |
+
negative_prompt: str = ""
|
| 32 |
+
num_frames: int = 81 # ~3 seconds at 24fps
|
| 33 |
+
fps: int = 24
|
| 34 |
+
seed: int = -1
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.post("/generate")
|
| 38 |
+
async def generate_video(
|
| 39 |
+
image: UploadFile = File(...),
|
| 40 |
+
prompt: str = Form(...),
|
| 41 |
+
negative_prompt: str = Form(""),
|
| 42 |
+
num_frames: int = Form(81),
|
| 43 |
+
fps: int = Form(24),
|
| 44 |
+
seed: int = Form(-1),
|
| 45 |
+
):
|
| 46 |
+
"""Generate a video from an image using WAN 2.2 I2V on the RunPod pod."""
|
| 47 |
+
import httpx
|
| 48 |
+
import random
|
| 49 |
+
import base64
|
| 50 |
+
|
| 51 |
+
pod_state = _get_pod_state()
|
| 52 |
+
|
| 53 |
+
if pod_state["status"] != "running":
|
| 54 |
+
raise HTTPException(400, "Pod not running - start it first in Status page")
|
| 55 |
+
|
| 56 |
+
job_id = str(uuid.uuid4())[:8]
|
| 57 |
+
seed = seed if seed >= 0 else random.randint(0, 2**32 - 1)
|
| 58 |
+
|
| 59 |
+
# Read the image
|
| 60 |
+
image_bytes = await image.read()
|
| 61 |
+
image_b64 = base64.b64encode(image_bytes).decode("utf-8")
|
| 62 |
+
|
| 63 |
+
# Build ComfyUI workflow for WAN 2.2 I2V
|
| 64 |
+
workflow = _build_wan_i2v_workflow(
|
| 65 |
+
image_b64=image_b64,
|
| 66 |
+
prompt=prompt,
|
| 67 |
+
negative_prompt=negative_prompt,
|
| 68 |
+
num_frames=num_frames,
|
| 69 |
+
fps=fps,
|
| 70 |
+
seed=seed,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 75 |
+
# First upload the image to ComfyUI
|
| 76 |
+
upload_url = f"http://{pod_state['ip']}:{pod_state['port']}/upload/image"
|
| 77 |
+
files = {"image": (f"input_{job_id}.png", image_bytes, "image/png")}
|
| 78 |
+
upload_resp = await client.post(upload_url, files=files)
|
| 79 |
+
|
| 80 |
+
if upload_resp.status_code != 200:
|
| 81 |
+
raise HTTPException(500, "Failed to upload image to pod")
|
| 82 |
+
|
| 83 |
+
upload_data = upload_resp.json()
|
| 84 |
+
uploaded_filename = upload_data.get("name", f"input_{job_id}.png")
|
| 85 |
+
|
| 86 |
+
# Update workflow with uploaded filename
|
| 87 |
+
workflow = _build_wan_i2v_workflow(
|
| 88 |
+
uploaded_filename=uploaded_filename,
|
| 89 |
+
prompt=prompt,
|
| 90 |
+
negative_prompt=negative_prompt,
|
| 91 |
+
num_frames=num_frames,
|
| 92 |
+
fps=fps,
|
| 93 |
+
seed=seed,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Submit workflow
|
| 97 |
+
url = f"http://{pod_state['ip']}:{pod_state['port']}/prompt"
|
| 98 |
+
resp = await client.post(url, json={"prompt": workflow})
|
| 99 |
+
resp.raise_for_status()
|
| 100 |
+
|
| 101 |
+
data = resp.json()
|
| 102 |
+
prompt_id = data["prompt_id"]
|
| 103 |
+
|
| 104 |
+
_video_jobs[job_id] = {
|
| 105 |
+
"prompt_id": prompt_id,
|
| 106 |
+
"status": "running",
|
| 107 |
+
"seed": seed,
|
| 108 |
+
"started_at": time.time(),
|
| 109 |
+
"num_frames": num_frames,
|
| 110 |
+
"fps": fps,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
logger.info("Video generation started: %s -> %s", job_id, prompt_id)
|
| 114 |
+
|
| 115 |
+
# Start background task to poll for completion
|
| 116 |
+
asyncio.create_task(_poll_video_job(job_id, prompt_id))
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
"job_id": job_id,
|
| 120 |
+
"status": "running",
|
| 121 |
+
"seed": seed,
|
| 122 |
+
"estimated_time": f"~{num_frames * 2} seconds",
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
except httpx.HTTPError as e:
|
| 126 |
+
logger.error("Video generation failed: %s", e)
|
| 127 |
+
raise HTTPException(500, f"Generation failed: {e}")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
async def _poll_video_job(job_id: str, prompt_id: str):
|
| 131 |
+
"""Poll ComfyUI for video job completion."""
|
| 132 |
+
import httpx
|
| 133 |
+
|
| 134 |
+
pod_state = _get_pod_state()
|
| 135 |
+
start = time.time()
|
| 136 |
+
timeout = 600 # 10 minutes for video
|
| 137 |
+
|
| 138 |
+
async with httpx.AsyncClient(timeout=60) as client:
|
| 139 |
+
while time.time() - start < timeout:
|
| 140 |
+
try:
|
| 141 |
+
url = f"http://{pod_state['ip']}:{pod_state['port']}/history/{prompt_id}"
|
| 142 |
+
resp = await client.get(url)
|
| 143 |
+
|
| 144 |
+
if resp.status_code == 200:
|
| 145 |
+
data = resp.json()
|
| 146 |
+
if prompt_id in data:
|
| 147 |
+
outputs = data[prompt_id].get("outputs", {})
|
| 148 |
+
|
| 149 |
+
# Find video output (SaveAnimatedWEBP or VHS_VideoCombine)
|
| 150 |
+
for node_id, node_output in outputs.items():
|
| 151 |
+
# Check for gifs/videos
|
| 152 |
+
if "gifs" in node_output:
|
| 153 |
+
video_info = node_output["gifs"][0]
|
| 154 |
+
await _download_video(client, job_id, video_info, pod_state)
|
| 155 |
+
return
|
| 156 |
+
# Check for images (animated)
|
| 157 |
+
if "images" in node_output:
|
| 158 |
+
img_info = node_output["images"][0]
|
| 159 |
+
if img_info.get("type") == "output":
|
| 160 |
+
await _download_video(client, job_id, img_info, pod_state)
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.debug("Polling video job: %s", e)
|
| 165 |
+
|
| 166 |
+
await asyncio.sleep(3)
|
| 167 |
+
|
| 168 |
+
_video_jobs[job_id]["status"] = "failed"
|
| 169 |
+
_video_jobs[job_id]["error"] = "Timeout waiting for video generation"
|
| 170 |
+
logger.error("Video generation timed out: %s", job_id)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def _download_video(client, job_id: str, video_info: dict, pod_state: dict):
|
| 174 |
+
"""Download the generated video from ComfyUI."""
|
| 175 |
+
filename = video_info.get("filename")
|
| 176 |
+
subfolder = video_info.get("subfolder", "")
|
| 177 |
+
file_type = video_info.get("type", "output")
|
| 178 |
+
|
| 179 |
+
# Download video
|
| 180 |
+
view_url = f"http://{pod_state['ip']}:{pod_state['port']}/view"
|
| 181 |
+
params = {"filename": filename, "type": file_type}
|
| 182 |
+
if subfolder:
|
| 183 |
+
params["subfolder"] = subfolder
|
| 184 |
+
|
| 185 |
+
video_resp = await client.get(view_url, params=params)
|
| 186 |
+
|
| 187 |
+
if video_resp.status_code == 200:
|
| 188 |
+
# Save to local output directory
|
| 189 |
+
from content_engine.config import settings
|
| 190 |
+
output_dir = settings.paths.output_dir / "videos"
|
| 191 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 192 |
+
|
| 193 |
+
# Determine extension
|
| 194 |
+
ext = Path(filename).suffix or ".webp"
|
| 195 |
+
local_path = output_dir / f"video_{job_id}{ext}"
|
| 196 |
+
local_path.write_bytes(video_resp.content)
|
| 197 |
+
|
| 198 |
+
_video_jobs[job_id]["status"] = "completed"
|
| 199 |
+
_video_jobs[job_id]["output_path"] = str(local_path)
|
| 200 |
+
_video_jobs[job_id]["completed_at"] = time.time()
|
| 201 |
+
_video_jobs[job_id]["filename"] = local_path.name
|
| 202 |
+
|
| 203 |
+
logger.info("Video saved: %s", local_path)
|
| 204 |
+
else:
|
| 205 |
+
_video_jobs[job_id]["status"] = "failed"
|
| 206 |
+
_video_jobs[job_id]["error"] = "Failed to download video"
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
@router.get("/jobs")
|
| 210 |
+
async def list_video_jobs():
|
| 211 |
+
"""List all video generation jobs."""
|
| 212 |
+
return list(_video_jobs.values())
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@router.get("/jobs/{job_id}")
|
| 216 |
+
async def get_video_job(job_id: str):
|
| 217 |
+
"""Get status of a video generation job."""
|
| 218 |
+
job = _video_jobs.get(job_id)
|
| 219 |
+
if not job:
|
| 220 |
+
raise HTTPException(404, "Job not found")
|
| 221 |
+
return job
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@router.get("/{filename}")
|
| 225 |
+
async def get_video_file(filename: str):
|
| 226 |
+
"""Serve a generated video file."""
|
| 227 |
+
from fastapi.responses import FileResponse
|
| 228 |
+
from content_engine.config import settings
|
| 229 |
+
|
| 230 |
+
video_path = settings.paths.output_dir / "videos" / filename
|
| 231 |
+
if not video_path.exists():
|
| 232 |
+
raise HTTPException(404, "Video not found")
|
| 233 |
+
|
| 234 |
+
media_type = "video/webm" if filename.endswith(".webm") else "image/webp"
|
| 235 |
+
return FileResponse(video_path, media_type=media_type)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _build_wan_i2v_workflow(
|
| 239 |
+
uploaded_filename: str = None,
|
| 240 |
+
image_b64: str = None,
|
| 241 |
+
prompt: str = "",
|
| 242 |
+
negative_prompt: str = "",
|
| 243 |
+
num_frames: int = 81,
|
| 244 |
+
fps: int = 24,
|
| 245 |
+
seed: int = -1,
|
| 246 |
+
) -> dict:
|
| 247 |
+
"""Build ComfyUI workflow for WAN 2.2 Image-to-Video."""
|
| 248 |
+
|
| 249 |
+
# WAN 2.2 I2V workflow
|
| 250 |
+
# This assumes the WAN 2.2 nodes are installed on the pod
|
| 251 |
+
workflow = {
|
| 252 |
+
# Load the input image
|
| 253 |
+
"1": {
|
| 254 |
+
"class_type": "LoadImage",
|
| 255 |
+
"inputs": {
|
| 256 |
+
"image": uploaded_filename or "input.png",
|
| 257 |
+
},
|
| 258 |
+
},
|
| 259 |
+
# WAN 2.2 model loader
|
| 260 |
+
"2": {
|
| 261 |
+
"class_type": "DownloadAndLoadWanModel",
|
| 262 |
+
"inputs": {
|
| 263 |
+
"model": "Wan2.2-I2V-14B-480P",
|
| 264 |
+
},
|
| 265 |
+
},
|
| 266 |
+
# Text encoder
|
| 267 |
+
"3": {
|
| 268 |
+
"class_type": "WanTextEncode",
|
| 269 |
+
"inputs": {
|
| 270 |
+
"prompt": prompt,
|
| 271 |
+
"negative_prompt": negative_prompt,
|
| 272 |
+
"wan_model": ["2", 0],
|
| 273 |
+
},
|
| 274 |
+
},
|
| 275 |
+
# Image-to-Video generation
|
| 276 |
+
"4": {
|
| 277 |
+
"class_type": "WanImageToVideo",
|
| 278 |
+
"inputs": {
|
| 279 |
+
"image": ["1", 0],
|
| 280 |
+
"wan_model": ["2", 0],
|
| 281 |
+
"conditioning": ["3", 0],
|
| 282 |
+
"num_frames": num_frames,
|
| 283 |
+
"seed": seed,
|
| 284 |
+
"steps": 30,
|
| 285 |
+
"cfg": 5.0,
|
| 286 |
+
},
|
| 287 |
+
},
|
| 288 |
+
# Decode to frames
|
| 289 |
+
"5": {
|
| 290 |
+
"class_type": "WanDecode",
|
| 291 |
+
"inputs": {
|
| 292 |
+
"samples": ["4", 0],
|
| 293 |
+
"wan_model": ["2", 0],
|
| 294 |
+
},
|
| 295 |
+
},
|
| 296 |
+
# Save as animated WEBP
|
| 297 |
+
"6": {
|
| 298 |
+
"class_type": "SaveAnimatedWEBP",
|
| 299 |
+
"inputs": {
|
| 300 |
+
"images": ["5", 0],
|
| 301 |
+
"filename_prefix": "wan_video",
|
| 302 |
+
"fps": fps,
|
| 303 |
+
"lossless": False,
|
| 304 |
+
"quality": 85,
|
| 305 |
+
},
|
| 306 |
+
},
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
return workflow
|
src/content_engine/api/ui.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/content_engine/config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration loader using Pydantic Settings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import yaml
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
from pydantic_settings import BaseSettings
|
| 12 |
+
|
| 13 |
+
# Detect if running on Hugging Face Spaces
|
| 14 |
+
IS_HF_SPACES = os.environ.get("HF_SPACES") == "1" or os.environ.get("SPACE_ID") is not None
|
| 15 |
+
|
| 16 |
+
# Base paths - use environment variables or defaults
|
| 17 |
+
if IS_HF_SPACES:
|
| 18 |
+
BASE_OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", "/app/data/output"))
|
| 19 |
+
BASE_DATA_DIR = Path(os.environ.get("DATA_DIR", "/app/data"))
|
| 20 |
+
BASE_DB_PATH = os.environ.get("DB_PATH", "/app/data/db/content_engine.db")
|
| 21 |
+
else:
|
| 22 |
+
BASE_OUTPUT_DIR = Path("D:/AI automation/output")
|
| 23 |
+
BASE_DATA_DIR = Path("D:/AI automation/data")
|
| 24 |
+
BASE_DB_PATH = "D:/AI automation/data/catalog.db"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ComfyUIConfig(BaseModel):
|
| 28 |
+
url: str = "http://127.0.0.1:8188"
|
| 29 |
+
max_local_queue_depth: int = 3
|
| 30 |
+
min_vram_gb: float = 2.0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PathsConfig(BaseModel):
|
| 34 |
+
output_dir: Path = BASE_OUTPUT_DIR
|
| 35 |
+
data_dir: Path = BASE_DATA_DIR
|
| 36 |
+
lora_dir: Path = Path("D:/ComfyUI/Models/Lora") if not IS_HF_SPACES else Path("/app/data/loras")
|
| 37 |
+
checkpoint_dir: Path = Path("D:/ComfyUI/Models/StableDiffusion") if not IS_HF_SPACES else Path("/app/data/models")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class DatabaseConfig(BaseModel):
|
| 41 |
+
url: str = f"sqlite+aiosqlite:///{BASE_DB_PATH}"
|
| 42 |
+
jobs_url: str = f"sqlite+aiosqlite:///{BASE_DATA_DIR}/jobs.db"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class GenerationConfig(BaseModel):
|
| 46 |
+
default_checkpoint: str = "realisticVisionV51_v51VAE.safetensors"
|
| 47 |
+
default_steps: int = 28
|
| 48 |
+
default_cfg: float = 7.0
|
| 49 |
+
default_sampler: str = "dpmpp_2m"
|
| 50 |
+
default_scheduler: str = "karras"
|
| 51 |
+
default_width: int = 832
|
| 52 |
+
default_height: int = 1216
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class SchedulingConfig(BaseModel):
|
| 56 |
+
posts_per_day: int = 3
|
| 57 |
+
peak_hours: list[int] = Field(default_factory=lambda: [10, 14, 20])
|
| 58 |
+
sfw_ratio: float = 0.4
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class CloudProviderEntry(BaseModel):
|
| 62 |
+
name: str
|
| 63 |
+
api_key: str = ""
|
| 64 |
+
priority: int = 1
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class Settings(BaseSettings):
|
| 68 |
+
comfyui: ComfyUIConfig = Field(default_factory=ComfyUIConfig)
|
| 69 |
+
paths: PathsConfig = Field(default_factory=PathsConfig)
|
| 70 |
+
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
| 71 |
+
generation: GenerationConfig = Field(default_factory=GenerationConfig)
|
| 72 |
+
scheduling: SchedulingConfig = Field(default_factory=SchedulingConfig)
|
| 73 |
+
cloud_providers: list[CloudProviderEntry] = Field(default_factory=list)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def load_settings(config_path: Path | None = None) -> Settings:
|
| 77 |
+
"""Load settings from YAML config file, with env var overrides."""
|
| 78 |
+
if config_path is None:
|
| 79 |
+
if IS_HF_SPACES:
|
| 80 |
+
config_path = Path("/app/config/settings.yaml")
|
| 81 |
+
else:
|
| 82 |
+
config_path = Path("D:/AI automation/content_engine/config/settings.yaml")
|
| 83 |
+
|
| 84 |
+
data: dict[str, Any] = {}
|
| 85 |
+
if config_path.exists():
|
| 86 |
+
with open(config_path) as f:
|
| 87 |
+
data = yaml.safe_load(f) or {}
|
| 88 |
+
|
| 89 |
+
return Settings(**data)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# Global singleton — initialized on import
|
| 93 |
+
settings = load_settings()
|
src/content_engine/main.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Content Engine — FastAPI application entry point.
|
| 2 |
+
|
| 3 |
+
Run with:
|
| 4 |
+
cd "D:\AI automation\content_engine"
|
| 5 |
+
uvicorn content_engine.main:app --host 0.0.0.0 --port 8000 --reload
|
| 6 |
+
|
| 7 |
+
Or:
|
| 8 |
+
python -m content_engine.main
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
from contextlib import asynccontextmanager
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
import yaml
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
from fastapi import FastAPI
|
| 21 |
+
|
| 22 |
+
from content_engine.config import settings
|
| 23 |
+
from content_engine.models.database import init_db
|
| 24 |
+
from content_engine.services.catalog import CatalogService
|
| 25 |
+
from content_engine.services.comfyui_client import ComfyUIClient
|
| 26 |
+
from content_engine.services.template_engine import TemplateEngine
|
| 27 |
+
from content_engine.services.variation_engine import CharacterProfile, VariationEngine
|
| 28 |
+
from content_engine.services.workflow_builder import WorkflowBuilder
|
| 29 |
+
from content_engine.workers.local_worker import LocalWorker
|
| 30 |
+
|
| 31 |
+
from content_engine.api import routes_catalog, routes_generation, routes_pod, routes_system, routes_training, routes_ui, routes_video
|
| 32 |
+
|
| 33 |
+
# Load .env file for API keys
|
| 34 |
+
import os
|
| 35 |
+
IS_HF_SPACES = os.environ.get("HF_SPACES") == "1" or os.environ.get("SPACE_ID") is not None
|
| 36 |
+
if IS_HF_SPACES:
|
| 37 |
+
load_dotenv(Path("/app/.env"))
|
| 38 |
+
else:
|
| 39 |
+
load_dotenv(Path("D:/AI automation/content_engine/.env"))
|
| 40 |
+
|
| 41 |
+
logging.basicConfig(
|
| 42 |
+
level=logging.INFO,
|
| 43 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 44 |
+
)
|
| 45 |
+
logger = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
# Service instances (created at startup)
|
| 48 |
+
comfyui_client: ComfyUIClient | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_character_profiles() -> dict[str, CharacterProfile]:
|
| 52 |
+
"""Load all character YAML profiles from config/characters/."""
|
| 53 |
+
if IS_HF_SPACES:
|
| 54 |
+
characters_dir = Path("/app/config/characters")
|
| 55 |
+
else:
|
| 56 |
+
characters_dir = Path("D:/AI automation/content_engine/config/characters")
|
| 57 |
+
profiles: dict[str, CharacterProfile] = {}
|
| 58 |
+
|
| 59 |
+
if not characters_dir.exists():
|
| 60 |
+
logger.warning("Characters directory not found: %s", characters_dir)
|
| 61 |
+
return profiles
|
| 62 |
+
|
| 63 |
+
for path in characters_dir.glob("*.yaml"):
|
| 64 |
+
try:
|
| 65 |
+
with open(path) as f:
|
| 66 |
+
data = yaml.safe_load(f)
|
| 67 |
+
profile = CharacterProfile(
|
| 68 |
+
id=data["id"],
|
| 69 |
+
name=data.get("name", data["id"]),
|
| 70 |
+
trigger_word=data["trigger_word"],
|
| 71 |
+
lora_filename=data["lora_filename"],
|
| 72 |
+
lora_strength=data.get("lora_strength", 0.85),
|
| 73 |
+
default_checkpoint=data.get("default_checkpoint"),
|
| 74 |
+
style_loras=data.get("style_loras", []),
|
| 75 |
+
description=data.get("description", ""),
|
| 76 |
+
physical_traits=data.get("physical_traits", {}),
|
| 77 |
+
)
|
| 78 |
+
profiles[profile.id] = profile
|
| 79 |
+
logger.info("Loaded character: %s (%s)", profile.name, profile.id)
|
| 80 |
+
except Exception:
|
| 81 |
+
logger.error("Failed to load character %s", path, exc_info=True)
|
| 82 |
+
|
| 83 |
+
return profiles
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@asynccontextmanager
|
| 87 |
+
async def lifespan(app: FastAPI):
|
| 88 |
+
"""Startup and shutdown lifecycle."""
|
| 89 |
+
global comfyui_client
|
| 90 |
+
|
| 91 |
+
logger.info("Starting Content Engine...")
|
| 92 |
+
|
| 93 |
+
# Initialize database
|
| 94 |
+
await init_db()
|
| 95 |
+
logger.info("Database initialized")
|
| 96 |
+
|
| 97 |
+
# Create service instances
|
| 98 |
+
comfyui_client = ComfyUIClient(settings.comfyui.url)
|
| 99 |
+
workflow_builder = WorkflowBuilder()
|
| 100 |
+
template_engine = TemplateEngine()
|
| 101 |
+
template_engine.load_all()
|
| 102 |
+
catalog = CatalogService()
|
| 103 |
+
character_profiles = load_character_profiles()
|
| 104 |
+
variation_engine = VariationEngine(template_engine)
|
| 105 |
+
|
| 106 |
+
local_worker = LocalWorker(
|
| 107 |
+
comfyui_client=comfyui_client,
|
| 108 |
+
workflow_builder=workflow_builder,
|
| 109 |
+
template_engine=template_engine,
|
| 110 |
+
catalog=catalog,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
# Check ComfyUI connection
|
| 114 |
+
if await comfyui_client.is_available():
|
| 115 |
+
logger.info("ComfyUI connected at %s", settings.comfyui.url)
|
| 116 |
+
else:
|
| 117 |
+
logger.warning(
|
| 118 |
+
"ComfyUI not available at %s — generation will fail until connected",
|
| 119 |
+
settings.comfyui.url,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# Initialize WaveSpeed cloud provider if API key is set
|
| 123 |
+
wavespeed_provider = None
|
| 124 |
+
wavespeed_key = os.environ.get("WAVESPEED_API_KEY")
|
| 125 |
+
if wavespeed_key:
|
| 126 |
+
from content_engine.services.cloud_providers.wavespeed_provider import WaveSpeedProvider
|
| 127 |
+
wavespeed_provider = WaveSpeedProvider(api_key=wavespeed_key)
|
| 128 |
+
logger.info("WaveSpeed cloud provider initialized (NanoBanana, SeeDream)")
|
| 129 |
+
else:
|
| 130 |
+
logger.info("WaveSpeed not configured — cloud generation disabled")
|
| 131 |
+
|
| 132 |
+
# Initialize route dependencies
|
| 133 |
+
routes_generation.init_routes(
|
| 134 |
+
local_worker, template_engine, variation_engine, character_profiles,
|
| 135 |
+
wavespeed_provider=wavespeed_provider, catalog=catalog,
|
| 136 |
+
comfyui_client=comfyui_client,
|
| 137 |
+
)
|
| 138 |
+
routes_catalog.init_routes(catalog)
|
| 139 |
+
routes_system.init_routes(comfyui_client, catalog, template_engine, character_profiles)
|
| 140 |
+
|
| 141 |
+
# Initialize LoRA trainer (local)
|
| 142 |
+
from content_engine.services.lora_trainer import LoRATrainer
|
| 143 |
+
lora_trainer = LoRATrainer()
|
| 144 |
+
logger.info("LoRA trainer initialized (sd-scripts %s)",
|
| 145 |
+
"ready" if lora_trainer.sd_scripts_installed else "not installed — install via UI")
|
| 146 |
+
|
| 147 |
+
# Initialize RunPod cloud trainer if API key is set
|
| 148 |
+
runpod_trainer = None
|
| 149 |
+
runpod_provider = None
|
| 150 |
+
runpod_key = os.environ.get("RUNPOD_API_KEY")
|
| 151 |
+
runpod_endpoint_id = os.environ.get("RUNPOD_ENDPOINT_ID")
|
| 152 |
+
|
| 153 |
+
if runpod_key:
|
| 154 |
+
from content_engine.services.runpod_trainer import RunPodTrainer
|
| 155 |
+
runpod_trainer = RunPodTrainer(api_key=runpod_key)
|
| 156 |
+
logger.info("RunPod cloud trainer initialized — cloud LoRA training available")
|
| 157 |
+
|
| 158 |
+
# Initialize RunPod generation provider if endpoint ID is set
|
| 159 |
+
if runpod_endpoint_id:
|
| 160 |
+
from content_engine.services.cloud_providers.runpod_provider import RunPodProvider
|
| 161 |
+
runpod_provider = RunPodProvider(api_key=runpod_key, endpoint_id=runpod_endpoint_id)
|
| 162 |
+
logger.info("RunPod generation provider initialized (endpoint: %s)", runpod_endpoint_id)
|
| 163 |
+
else:
|
| 164 |
+
logger.info("RunPod endpoint not configured — set RUNPOD_ENDPOINT_ID for cloud generation")
|
| 165 |
+
else:
|
| 166 |
+
logger.info("RunPod not configured — set RUNPOD_API_KEY for cloud training/generation")
|
| 167 |
+
|
| 168 |
+
routes_training.init_routes(lora_trainer, runpod_trainer=runpod_trainer)
|
| 169 |
+
|
| 170 |
+
# Update generation routes with RunPod provider
|
| 171 |
+
routes_generation.set_runpod_provider(runpod_provider)
|
| 172 |
+
|
| 173 |
+
logger.info(
|
| 174 |
+
"Content Engine ready — %d templates, %d characters",
|
| 175 |
+
len(template_engine.list_templates()),
|
| 176 |
+
len(character_profiles),
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
yield
|
| 180 |
+
|
| 181 |
+
# Shutdown
|
| 182 |
+
if comfyui_client:
|
| 183 |
+
await comfyui_client.close()
|
| 184 |
+
logger.info("Content Engine stopped")
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# Create the FastAPI app
|
| 188 |
+
app = FastAPI(
|
| 189 |
+
title="Content Engine",
|
| 190 |
+
description="Automated content generation system using ComfyUI",
|
| 191 |
+
version="0.1.0",
|
| 192 |
+
lifespan=lifespan,
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Register route modules
|
| 196 |
+
app.include_router(routes_ui.router) # UI at / (must be first)
|
| 197 |
+
app.include_router(routes_generation.router)
|
| 198 |
+
app.include_router(routes_catalog.router)
|
| 199 |
+
app.include_router(routes_system.router)
|
| 200 |
+
app.include_router(routes_training.router)
|
| 201 |
+
app.include_router(routes_pod.router)
|
| 202 |
+
app.include_router(routes_video.router)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
if __name__ == "__main__":
|
| 206 |
+
import uvicorn
|
| 207 |
+
|
| 208 |
+
uvicorn.run(
|
| 209 |
+
"content_engine.main:app",
|
| 210 |
+
host="0.0.0.0",
|
| 211 |
+
port=8000,
|
| 212 |
+
reload=True,
|
| 213 |
+
)
|
src/content_engine/models/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database models and Pydantic schemas."""
|
| 2 |
+
|
| 3 |
+
from content_engine.models.database import (
|
| 4 |
+
Base,
|
| 5 |
+
Character,
|
| 6 |
+
GenerationJob,
|
| 7 |
+
Image,
|
| 8 |
+
ScheduledPost,
|
| 9 |
+
)
|
| 10 |
+
from content_engine.models.schemas import (
|
| 11 |
+
BatchRequest,
|
| 12 |
+
GenerationRequest,
|
| 13 |
+
GenerationResponse,
|
| 14 |
+
ImageResponse,
|
| 15 |
+
JobStatus,
|
| 16 |
+
SystemStatus,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
"Base",
|
| 21 |
+
"Character",
|
| 22 |
+
"GenerationJob",
|
| 23 |
+
"Image",
|
| 24 |
+
"ScheduledPost",
|
| 25 |
+
"BatchRequest",
|
| 26 |
+
"GenerationRequest",
|
| 27 |
+
"GenerationResponse",
|
| 28 |
+
"ImageResponse",
|
| 29 |
+
"JobStatus",
|
| 30 |
+
"SystemStatus",
|
| 31 |
+
]
|
src/content_engine/models/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (802 Bytes). View file
|
|
|
src/content_engine/models/__pycache__/database.cpython-311.pyc
ADDED
|
Binary file (10.7 kB). View file
|
|
|
src/content_engine/models/__pycache__/schemas.cpython-311.pyc
ADDED
|
Binary file (5.59 kB). View file
|
|
|
src/content_engine/models/database.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy database models for the content catalog and job queue."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import (
|
| 8 |
+
Boolean,
|
| 9 |
+
DateTime,
|
| 10 |
+
Float,
|
| 11 |
+
Index,
|
| 12 |
+
Integer,
|
| 13 |
+
String,
|
| 14 |
+
Text,
|
| 15 |
+
func,
|
| 16 |
+
)
|
| 17 |
+
from sqlalchemy.ext.asyncio import AsyncAttrs, async_sessionmaker, create_async_engine
|
| 18 |
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
| 19 |
+
|
| 20 |
+
from content_engine.config import settings
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class Base(AsyncAttrs, DeclarativeBase):
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Character(Base):
|
| 28 |
+
__tablename__ = "characters"
|
| 29 |
+
|
| 30 |
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
| 31 |
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
| 32 |
+
trigger_word: Mapped[str] = mapped_column(String(128), nullable=False)
|
| 33 |
+
lora_filename: Mapped[str] = mapped_column(String(256), nullable=False)
|
| 34 |
+
lora_strength: Mapped[float] = mapped_column(Float, default=0.85)
|
| 35 |
+
default_checkpoint: Mapped[str | None] = mapped_column(String(256))
|
| 36 |
+
description: Mapped[str | None] = mapped_column(Text)
|
| 37 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 38 |
+
DateTime, server_default=func.now()
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class Image(Base):
|
| 43 |
+
__tablename__ = "images"
|
| 44 |
+
|
| 45 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
| 46 |
+
batch_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
| 47 |
+
character_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
| 48 |
+
template_id: Mapped[str | None] = mapped_column(String(128))
|
| 49 |
+
content_rating: Mapped[str] = mapped_column(String(8), index=True) # sfw | nsfw
|
| 50 |
+
|
| 51 |
+
# Generation parameters
|
| 52 |
+
positive_prompt: Mapped[str | None] = mapped_column(Text)
|
| 53 |
+
negative_prompt: Mapped[str | None] = mapped_column(Text)
|
| 54 |
+
checkpoint: Mapped[str | None] = mapped_column(String(256))
|
| 55 |
+
loras_json: Mapped[str | None] = mapped_column(Text) # JSON array
|
| 56 |
+
seed: Mapped[int | None] = mapped_column(Integer)
|
| 57 |
+
steps: Mapped[int | None] = mapped_column(Integer)
|
| 58 |
+
cfg: Mapped[float | None] = mapped_column(Float)
|
| 59 |
+
sampler: Mapped[str | None] = mapped_column(String(64))
|
| 60 |
+
scheduler: Mapped[str | None] = mapped_column(String(64))
|
| 61 |
+
width: Mapped[int | None] = mapped_column(Integer)
|
| 62 |
+
height: Mapped[int | None] = mapped_column(Integer)
|
| 63 |
+
|
| 64 |
+
# Searchable variation attributes
|
| 65 |
+
pose: Mapped[str | None] = mapped_column(String(128))
|
| 66 |
+
outfit: Mapped[str | None] = mapped_column(String(128))
|
| 67 |
+
emotion: Mapped[str | None] = mapped_column(String(128))
|
| 68 |
+
camera_angle: Mapped[str | None] = mapped_column(String(128))
|
| 69 |
+
lighting: Mapped[str | None] = mapped_column(String(128))
|
| 70 |
+
scene: Mapped[str | None] = mapped_column(String(128))
|
| 71 |
+
|
| 72 |
+
# File info
|
| 73 |
+
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
|
| 74 |
+
file_hash: Mapped[str | None] = mapped_column(String(64))
|
| 75 |
+
file_size: Mapped[int | None] = mapped_column(Integer)
|
| 76 |
+
generation_backend: Mapped[str | None] = mapped_column(String(32)) # local | cloud
|
| 77 |
+
comfyui_prompt_id: Mapped[str | None] = mapped_column(String(36))
|
| 78 |
+
generation_time_seconds: Mapped[float | None] = mapped_column(Float)
|
| 79 |
+
|
| 80 |
+
# Quality and publishing
|
| 81 |
+
quality_score: Mapped[float | None] = mapped_column(Float)
|
| 82 |
+
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 83 |
+
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 84 |
+
published_platform: Mapped[str | None] = mapped_column(String(64))
|
| 85 |
+
published_at: Mapped[datetime | None] = mapped_column(DateTime)
|
| 86 |
+
scheduled_at: Mapped[datetime | None] = mapped_column(DateTime)
|
| 87 |
+
|
| 88 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 89 |
+
DateTime, server_default=func.now()
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
__table_args__ = (
|
| 93 |
+
Index("idx_images_approved", "is_approved", postgresql_where=(is_approved == True)), # noqa: E712
|
| 94 |
+
Index(
|
| 95 |
+
"idx_images_unpublished",
|
| 96 |
+
"is_published",
|
| 97 |
+
"is_approved",
|
| 98 |
+
),
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class GenerationJob(Base):
|
| 103 |
+
__tablename__ = "generation_jobs"
|
| 104 |
+
|
| 105 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
| 106 |
+
batch_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
| 107 |
+
character_id: Mapped[str | None] = mapped_column(String(64))
|
| 108 |
+
template_id: Mapped[str | None] = mapped_column(String(128))
|
| 109 |
+
content_rating: Mapped[str | None] = mapped_column(String(8))
|
| 110 |
+
variables_json: Mapped[str | None] = mapped_column(Text)
|
| 111 |
+
workflow_json: Mapped[str | None] = mapped_column(Text)
|
| 112 |
+
backend: Mapped[str | None] = mapped_column(String(32)) # local | replicate | runpod
|
| 113 |
+
status: Mapped[str] = mapped_column(
|
| 114 |
+
String(16), default="pending", index=True
|
| 115 |
+
) # pending | queued | running | completed | failed
|
| 116 |
+
comfyui_prompt_id: Mapped[str | None] = mapped_column(String(36))
|
| 117 |
+
cloud_job_id: Mapped[str | None] = mapped_column(String(128))
|
| 118 |
+
result_image_id: Mapped[str | None] = mapped_column(String(36))
|
| 119 |
+
error_message: Mapped[str | None] = mapped_column(Text)
|
| 120 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 121 |
+
DateTime, server_default=func.now()
|
| 122 |
+
)
|
| 123 |
+
started_at: Mapped[datetime | None] = mapped_column(DateTime)
|
| 124 |
+
completed_at: Mapped[datetime | None] = mapped_column(DateTime)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class ScheduledPost(Base):
|
| 128 |
+
__tablename__ = "scheduled_posts"
|
| 129 |
+
|
| 130 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
| 131 |
+
image_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
| 132 |
+
platform: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 133 |
+
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
| 134 |
+
caption: Mapped[str | None] = mapped_column(Text)
|
| 135 |
+
is_teaser: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 136 |
+
status: Mapped[str] = mapped_column(
|
| 137 |
+
String(16), default="pending"
|
| 138 |
+
) # pending | published | failed | cancelled
|
| 139 |
+
published_at: Mapped[datetime | None] = mapped_column(DateTime)
|
| 140 |
+
error_message: Mapped[str | None] = mapped_column(Text)
|
| 141 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 142 |
+
DateTime, server_default=func.now()
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
__table_args__ = (
|
| 146 |
+
Index("idx_scheduled_pending", "status", "scheduled_at"),
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# --- Engine / Session factories ---
|
| 151 |
+
|
| 152 |
+
_catalog_engine = create_async_engine(
|
| 153 |
+
settings.database.url,
|
| 154 |
+
echo=False,
|
| 155 |
+
connect_args={"check_same_thread": False}, # SQLite specific
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
catalog_session_factory = async_sessionmaker(
|
| 159 |
+
_catalog_engine, expire_on_commit=False
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
async def init_db() -> None:
|
| 164 |
+
"""Create all tables. Call once at startup."""
|
| 165 |
+
async with _catalog_engine.begin() as conn:
|
| 166 |
+
await conn.run_sync(Base.metadata.create_all)
|
src/content_engine/models/schemas.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request/response schemas for the API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# --- Request schemas ---
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LoRASpec(BaseModel):
|
| 14 |
+
name: str
|
| 15 |
+
strength_model: float = 0.85
|
| 16 |
+
strength_clip: float = 0.85
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class GenerationRequest(BaseModel):
|
| 20 |
+
"""Single image generation request."""
|
| 21 |
+
|
| 22 |
+
character_id: str | None = None
|
| 23 |
+
template_id: str | None = None
|
| 24 |
+
content_rating: str = "sfw" # sfw | nsfw
|
| 25 |
+
|
| 26 |
+
# Direct prompt override (if not using template)
|
| 27 |
+
positive_prompt: str | None = None
|
| 28 |
+
negative_prompt: str | None = None
|
| 29 |
+
|
| 30 |
+
# Model configuration
|
| 31 |
+
checkpoint: str | None = None
|
| 32 |
+
loras: list[LoRASpec] = Field(default_factory=list)
|
| 33 |
+
|
| 34 |
+
# Sampler settings
|
| 35 |
+
seed: int | None = None
|
| 36 |
+
steps: int | None = None
|
| 37 |
+
cfg: float | None = None
|
| 38 |
+
sampler: str | None = None
|
| 39 |
+
scheduler: str | None = None
|
| 40 |
+
width: int | None = None
|
| 41 |
+
height: int | None = None
|
| 42 |
+
|
| 43 |
+
# Variation variables (for template rendering)
|
| 44 |
+
variables: dict[str, str] = Field(default_factory=dict)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class BatchRequest(BaseModel):
|
| 48 |
+
"""Batch generation request."""
|
| 49 |
+
|
| 50 |
+
character_id: str
|
| 51 |
+
template_id: str
|
| 52 |
+
content_rating: str = "sfw"
|
| 53 |
+
count: int = 10
|
| 54 |
+
variation_mode: str = "random" # curated | random | exhaustive
|
| 55 |
+
pin: dict[str, str] = Field(default_factory=dict)
|
| 56 |
+
seed_strategy: str = "random" # random | sequential | fixed
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# --- Response schemas ---
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class GenerationResponse(BaseModel):
|
| 63 |
+
job_id: str
|
| 64 |
+
batch_id: str | None = None
|
| 65 |
+
status: str
|
| 66 |
+
backend: str | None = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class JobStatus(BaseModel):
|
| 70 |
+
job_id: str
|
| 71 |
+
batch_id: str | None = None
|
| 72 |
+
status: str # pending | queued | running | completed | failed
|
| 73 |
+
backend: str | None = None
|
| 74 |
+
progress: float | None = None # 0.0 - 1.0
|
| 75 |
+
result_image_id: str | None = None
|
| 76 |
+
error_message: str | None = None
|
| 77 |
+
created_at: datetime | None = None
|
| 78 |
+
started_at: datetime | None = None
|
| 79 |
+
completed_at: datetime | None = None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ImageResponse(BaseModel):
|
| 83 |
+
id: str
|
| 84 |
+
character_id: str | None = None
|
| 85 |
+
template_id: str | None = None
|
| 86 |
+
content_rating: str
|
| 87 |
+
file_path: str
|
| 88 |
+
seed: int | None = None
|
| 89 |
+
pose: str | None = None
|
| 90 |
+
outfit: str | None = None
|
| 91 |
+
emotion: str | None = None
|
| 92 |
+
camera_angle: str | None = None
|
| 93 |
+
lighting: str | None = None
|
| 94 |
+
scene: str | None = None
|
| 95 |
+
quality_score: float | None = None
|
| 96 |
+
is_approved: bool
|
| 97 |
+
is_published: bool
|
| 98 |
+
created_at: datetime | None = None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class SystemStatus(BaseModel):
|
| 102 |
+
comfyui_connected: bool
|
| 103 |
+
gpu_name: str | None = None
|
| 104 |
+
vram_total_gb: float | None = None
|
| 105 |
+
vram_free_gb: float | None = None
|
| 106 |
+
local_queue_depth: int = 0
|
| 107 |
+
cloud_available: bool = False
|
| 108 |
+
total_images: int = 0
|
| 109 |
+
pending_jobs: int = 0
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class BatchStatusResponse(BaseModel):
|
| 113 |
+
batch_id: str
|
| 114 |
+
total_jobs: int
|
| 115 |
+
completed: int
|
| 116 |
+
failed: int
|
| 117 |
+
pending: int
|
| 118 |
+
running: int
|
src/content_engine/services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Services layer for the content engine."""
|
src/content_engine/services/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (232 Bytes). View file
|
|
|