dippoo commited on
Commit
ed37502
·
1 Parent(s): 5a7d186

Initial deployment - Content Engine

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +40 -0
  2. README.md +41 -10
  3. config/characters/example_character.yaml +29 -0
  4. config/models.yaml +95 -0
  5. config/settings.yaml +47 -0
  6. config/templates/prompts/artistic_nude.yaml +100 -0
  7. config/templates/prompts/boudoir_intimate.yaml +112 -0
  8. config/templates/prompts/lifestyle_casual.yaml +88 -0
  9. config/templates/prompts/portrait_glamour.yaml +108 -0
  10. config/templates/workflows/sd15_base_nsfw.json +59 -0
  11. config/templates/workflows/sd15_base_sfw.json +59 -0
  12. config/templates/workflows/sd15_img2img_nsfw.json +64 -0
  13. config/templates/workflows/sd15_img2img_sfw.json +64 -0
  14. requirements.txt +15 -0
  15. src/content_engine.egg-info/PKG-INFO +25 -0
  16. src/content_engine.egg-info/SOURCES.txt +30 -0
  17. src/content_engine.egg-info/dependency_links.txt +1 -0
  18. src/content_engine.egg-info/requires.txt +22 -0
  19. src/content_engine.egg-info/top_level.txt +1 -0
  20. src/content_engine/__init__.py +3 -0
  21. src/content_engine/__pycache__/__init__.cpython-311.pyc +0 -0
  22. src/content_engine/__pycache__/config.cpython-311.pyc +0 -0
  23. src/content_engine/__pycache__/main.cpython-311.pyc +0 -0
  24. src/content_engine/api/__init__.py +1 -0
  25. src/content_engine/api/__pycache__/__init__.cpython-311.pyc +0 -0
  26. src/content_engine/api/__pycache__/routes_catalog.cpython-311.pyc +0 -0
  27. src/content_engine/api/__pycache__/routes_generation.cpython-311.pyc +0 -0
  28. src/content_engine/api/__pycache__/routes_pod.cpython-311.pyc +0 -0
  29. src/content_engine/api/__pycache__/routes_system.cpython-311.pyc +0 -0
  30. src/content_engine/api/__pycache__/routes_training.cpython-311.pyc +0 -0
  31. src/content_engine/api/__pycache__/routes_ui.cpython-311.pyc +0 -0
  32. src/content_engine/api/__pycache__/routes_video.cpython-311.pyc +0 -0
  33. src/content_engine/api/routes_catalog.py +169 -0
  34. src/content_engine/api/routes_generation.py +604 -0
  35. src/content_engine/api/routes_pod.py +545 -0
  36. src/content_engine/api/routes_system.py +235 -0
  37. src/content_engine/api/routes_training.py +269 -0
  38. src/content_engine/api/routes_ui.py +23 -0
  39. src/content_engine/api/routes_video.py +309 -0
  40. src/content_engine/api/ui.html +0 -0
  41. src/content_engine/config.py +93 -0
  42. src/content_engine/main.py +213 -0
  43. src/content_engine/models/__init__.py +31 -0
  44. src/content_engine/models/__pycache__/__init__.cpython-311.pyc +0 -0
  45. src/content_engine/models/__pycache__/database.cpython-311.pyc +0 -0
  46. src/content_engine/models/__pycache__/schemas.cpython-311.pyc +0 -0
  47. src/content_engine/models/database.py +166 -0
  48. src/content_engine/models/schemas.py +118 -0
  49. src/content_engine/services/__init__.py +1 -0
  50. 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
- title: Content Engine
3
- emoji: 🏢
4
- colorFrom: red
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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