Compare commits

...

46 Commits

Author SHA1 Message Date
mike
5a8acece09 Summary
• Implemented robust model and scene reference validations in both the FastAPI backend and frontend to prevent empty, corrupt, or missing files from silently failing or producing duplicated background
   fallbacks.
   • Enhanced scenery prompt translation on the backend to dynamically map user-specified "Image" numbers e.g. Image 1, Image 2 to Qwen-specific "Picture" placeholders Picture 1, Picture 2, avoiding template
   mismatch desyncs.

   Changes
   • File & Path Validation: Added detailed validations to the /generate-scenery endpoint to ensure model_filename and scene_image paths exist on disk, are resolved without query parameters stripping ?t=..., are
   not directories, and are openable and verifiable as PIL images.
   • Identical Reference Checks: Introduced checks in both the backend and frontend submitGenerateScenery inside car.html to raise an HTTP 400 exception / display a toast warning if the model and scene references
   point to the same image.
   • Dynamic Prompt Mapping: Configured the backend to auto-translate occurrences of image 1/2/3 in custom scenery prompts to Picture 1/2/3, facilitating correct Qwen layout hint targeting.
   • Regression Unit Tests: Wrote comprehensive unit tests test_13b_scenery_validation_checks in test_regression_api.py verifying file checking, duplicate reference detection, and query parameter stripping.

   Verification
   • Executed test_regression_api.py simulated API testing with 100% success 19/19 tests passed.
   • Executed test_regression_qwen.py end-to-end model/hardware integration test suite successfully 2/2 tests passed.
2026-07-01 12:09:53 +02:00
mike
ec4fa1aa72 updates UI 2026-07-01 06:43:29 +02:00
mike
94419d7673 updates UI 2026-07-01 05:42:20 +02:00
mike
251d9b1cc8 updates UI 2026-07-01 04:46:43 +02:00
mike
970daeba31 updates UI 2026-07-01 03:46:10 +02:00
mike
145fa686e4 updates UI 2026-07-01 02:25:51 +02:00
mike
66685684c1 updates UI 2026-06-30 04:05:18 +02:00
mike
5cea78c406 updates UI 2026-06-30 02:27:31 +02:00
mike
ad9a2ae078 updates UI 2026-06-30 01:07:54 +02:00
mike
61268de34b updates UI 2026-06-29 14:10:58 +02:00
mike
139785d108 updates UI 2026-06-29 12:56:31 +02:00
mike
150ef6dab0 Summary
• Implemented global offline mode for all HuggingFace-dependent modules to eliminate unauthenticated request warnings and prevent external connection attempts during startup and runtime.

   Changes
   • Global Offline Configuration: Added HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 to the environment variables at the top of edit_api.py, embeddings.py, watcher.py, and pose_llm/pose_llm_api.py.
   • Explicit Local Files Only: Updated all huggingface_hub.hf_hub_download calls in edit_api.py to include local_files_only=True, ensuring they never attempt to reach the Hub if models are already cached.
   • LLM Service Optimization: Updated AutoTokenizer and AutoModelForCausalLM calls in pose_llm/pose_llm_api.py to use local_files_only=True.
   • Consistency: Ensured that shared modules like embeddings.py which uses open_clip are also locked to offline mode to prevent background downloads.
2026-06-29 12:27:10 +02:00
mike
3b9b9e829b Summary
• Implemented global offline mode for all HuggingFace-dependent modules to eliminate unauthenticated request warnings and prevent external connection attempts during startup and runtime.

   Changes
   • Global Offline Configuration: Added HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 to the environment variables at the top of edit_api.py, embeddings.py, watcher.py, and pose_llm/pose_llm_api.py.
   • Explicit Local Files Only: Updated all huggingface_hub.hf_hub_download calls in edit_api.py to include local_files_only=True, ensuring they never attempt to reach the Hub if models are already cached.
   • LLM Service Optimization: Updated AutoTokenizer and AutoModelForCausalLM calls in pose_llm/pose_llm_api.py to use local_files_only=True.
   • Consistency: Ensured that shared modules like embeddings.py which uses open_clip are also locked to offline mode to prevent background downloads.
2026-06-28 16:56:07 +02:00
mike
da1fd4a26c Summary
• Implemented global offline mode for all HuggingFace-dependent modules to eliminate unauthenticated request warnings and prevent external connection attempts during startup and runtime.

   Changes
   • Global Offline Configuration: Added HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 to the environment variables at the top of edit_api.py, embeddings.py, watcher.py, and pose_llm/pose_llm_api.py.
   • Explicit Local Files Only: Updated all huggingface_hub.hf_hub_download calls in edit_api.py to include local_files_only=True, ensuring they never attempt to reach the Hub if models are already cached.
   • LLM Service Optimization: Updated AutoTokenizer and AutoModelForCausalLM calls in pose_llm/pose_llm_api.py to use local_files_only=True.
   • Consistency: Ensured that shared modules like embeddings.py which uses open_clip are also locked to offline mode to prevent background downloads.
2026-06-28 13:40:29 +02:00
mike
684a4805d7 Summary
• Implemented global offline mode for all HuggingFace-dependent modules to eliminate unauthenticated request warnings and prevent external connection attempts during startup and runtime.

   Changes
   • Global Offline Configuration: Added HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 to the environment variables at the top of edit_api.py, embeddings.py, watcher.py, and pose_llm/pose_llm_api.py.
   • Explicit Local Files Only: Updated all huggingface_hub.hf_hub_download calls in edit_api.py to include local_files_only=True, ensuring they never attempt to reach the Hub if models are already cached.
   • LLM Service Optimization: Updated AutoTokenizer and AutoModelForCausalLM calls in pose_llm/pose_llm_api.py to use local_files_only=True.
   • Consistency: Ensured that shared modules like embeddings.py which uses open_clip are also locked to offline mode to prevent background downloads.
2026-06-28 13:12:05 +02:00
mike
30dcb10727 Summary
• Implemented a system-wide privacy lock that automatically hides the studio interface when the OS is locked or when triggered via a new API endpoint.

   Changes
   • Backend edit_api.py:
     • Added a background monitor daemon that uses gdbus to listen for Ubuntu/GNOME screen lock events org.gnome.ScreenSaver.ActiveChanged.
     • Introduced a global _privacy_locked state synchronized across the backend.
     • Added new API endpoints: GET /privacy/status, POST /privacy/lock, and POST /privacy/unlock to allow external triggers e.g., keyboard macros.
     • Updated the static data exporter to include system_status.json, enabling efficient frontend polling.
   • Frontend car.html:
     • Added a 3-second polling mechanism to check for the system lock state.
     • Implemented auto-activation of Privacy Mode and the privacy overlay when a system lock transition is detected.
     • Added a visual toast notification when the app is auto-locked by the system.

   Verification
   • Verified backend code integrity via py_compile.
   • Confirmed that the gdbus monitor command correctly identifies GNOME lock states.
   • Ensured the frontend polling logic correctly handles transitions without redundant UI flickering.

   Notes
   • To map the Logitech Craft multimedia button top left, use a tool like Solaar or Ubuntu's Custom Shortcuts to execute: curl -X POST http://localhost:8500/privacy/lock. This will instantly hide the app regardless of browser focus.
2026-06-28 12:56:36 +02:00
mike
de71049079 Summary
• Implemented a system-wide privacy lock that automatically hides the studio interface when the OS is locked or when triggered via a new API endpoint.

   Changes
   • Backend edit_api.py:
     • Added a background monitor daemon that uses gdbus to listen for Ubuntu/GNOME screen lock events org.gnome.ScreenSaver.ActiveChanged.
     • Introduced a global _privacy_locked state synchronized across the backend.
     • Added new API endpoints: GET /privacy/status, POST /privacy/lock, and POST /privacy/unlock to allow external triggers e.g., keyboard macros.
     • Updated the static data exporter to include system_status.json, enabling efficient frontend polling.
   • Frontend car.html:
     • Added a 3-second polling mechanism to check for the system lock state.
     • Implemented auto-activation of Privacy Mode and the privacy overlay when a system lock transition is detected.
     • Added a visual toast notification when the app is auto-locked by the system.

   Verification
   • Verified backend code integrity via py_compile.
   • Confirmed that the gdbus monitor command correctly identifies GNOME lock states.
   • Ensured the frontend polling logic correctly handles transitions without redundant UI flickering.

   Notes
   • To map the Logitech Craft multimedia button top left, use a tool like Solaar or Ubuntu's Custom Shortcuts to execute: curl -X POST http://localhost:8500/privacy/lock. This will instantly hide the app regardless of browser focus.
2026-06-28 12:39:19 +02:00
mike
10849ed21a reorder 2026-06-28 10:12:01 +02:00
mike
3386ec9e84 reorder 2026-06-28 05:48:36 +02:00
mike
419f732cf6 reorder 2026-06-28 05:02:44 +02:00
mike
256a3b6ac8 reorder 2026-06-28 04:51:24 +02:00
mike
d522b2a267 reorder 2026-06-28 03:33:02 +02:00
mike
6ad11fc6c0 reorder 2026-06-28 02:29:31 +02:00
mike
3f91694491 reorder 2026-06-28 00:51:07 +02:00
mike
19e0656ccb reorder 2026-06-28 00:23:48 +02:00
mike
03b754112c reorder 2026-06-28 00:09:26 +02:00
mike
bf3773bfd4 reorder 2026-06-27 23:36:41 +02:00
mike
2ada5fb559 reorder 2026-06-27 23:01:16 +02:00
mike
52ae51fef3 reorder 2026-06-27 20:23:14 +02:00
mike
35306327f7 reorder 2026-06-27 15:07:38 +02:00
mike
08a8cc9b82 reorder 2026-06-27 13:51:51 +02:00
mike
4e23374093 reorder 2026-06-27 01:22:34 +02:00
mike
4f388901f3 reorder 2026-06-27 01:22:26 +02:00
mike
36a244cab4 reorder 2026-06-27 00:45:45 +02:00
mike
7eb544211e reorder 2026-06-27 00:44:56 +02:00
mike
78ffb029b9 reorder 2026-06-27 00:39:32 +02:00
mike
61cd2e559b reorder 2026-06-26 17:28:17 +02:00
mike
37bf5281c3 outpaint orbit 2026-06-26 03:26:27 +02:00
mike
80b901ac8a outpaint orbit 2026-06-26 02:34:12 +02:00
mike
ce7b0e52e5 aa 2026-06-25 19:05:21 +02:00
mike
27e8a09bc1 dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel. 2026-06-25 03:31:54 +02:00
mike
7b12ebd866 dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel. 2026-06-25 02:28:45 +02:00
mike
6d31826c29 dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel. 2026-06-25 00:57:29 +02:00
mike
ee7569f38c dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel. 2026-06-24 22:56:01 +02:00
mike
54d96ef580 dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel. 2026-06-24 21:27:11 +02:00
mike
8df588e594 dphn/Dolphin3.0-Mistral-24B is the ungated mirror of the Dolphin 3.0 Mistral 24B — exactly what you asked for. It's ~48GB fp16, which needs GPU+CPU split (device_map="auto" with 32GB on GPU, ~16GB in RAM). Let me kick off the download and update the service in parallel. 2026-06-24 13:10:07 +02:00
51 changed files with 24126 additions and 5278 deletions

View File

@@ -1,4 +1,6 @@
import os
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
import time
import json
import shutil
@@ -58,8 +60,11 @@ def get_processed_files():
def save_processed_files(processed):
try:
with open(CONF["processed_file"], 'w') as f:
p = CONF["processed_file"]
tmp = p + ".tmp"
with open(tmp, 'w') as f:
json.dump(processed, f, indent=2)
os.replace(tmp, p)
except Exception as e:
logging.error(f"Error saving processed file: {e}")
@@ -162,14 +167,22 @@ def process_image(filename):
# Save temporary cropped image for upload
cropped_img.save(temp_path, format="PNG")
prompt = CONF.get("prompt")
if not prompt:
bp = CONF.get("base_prompts", [])
if bp and isinstance(bp, list) and len(bp) > 0:
prompt = bp[0]
else:
prompt = "high quality, masterpiece"
with open(temp_path, 'rb') as f:
files = {'image': (filename, f, 'image/png')}
data = {
'prompt': CONF["prompt"],
'prompt': prompt,
'seed': CONF.get("seed", -1),
'max_area': CONF.get("max_area", 0)
}
logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {CONF['prompt']}")
logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {prompt}")
response = requests.post(CONF["api_url"], files=files, data=data, timeout=600)
if response.status_code == 200:
@@ -181,7 +194,7 @@ def process_image(filename):
try:
embedding = embeddings.generate_embedding(output_path)
gid = filename
database.upsert_person(output_filename, filepath=output_path, embedding=embedding, group_id=gid)
database.upsert_person(output_filename, filepath=output_path, embedding=embedding, group_id=gid, is_source=True)
# Also trigger tagging to get auto-name and clip description
tag_url = CONF["api_url"].replace("/edit", "/tag")
@@ -219,9 +232,14 @@ def update_car_html():
return
try:
# Use database to list only non-archived images
persons = database.list_persons(include_archived=False)
db_images = {p[0] for p in persons}
# List images in output_dir
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
images = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions) and f != "car.html"]
images = [f for f in os.listdir(output_dir)
if f.lower().endswith(extensions) and f != "car.html" and f in db_images]
# Sort by mtime, newest first
images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
@@ -238,9 +256,11 @@ def update_car_html():
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
with open(car_html_path, 'w') as f:
tmp_path = car_html_path + ".tmp"
with open(tmp_path, 'w') as f:
f.write(new_content)
logging.info(f"Updated {car_html_path} with {len(images)} images")
os.replace(tmp_path, car_html_path)
logging.info(f"Updated {car_html_path} atomically with {len(images)} images")
except Exception as e:
logging.error(f"Failed to update car.html: {e}")

248
AGENTS.md
View File

@@ -63,30 +63,136 @@ FastAPI :8500 (edit_api.py)
- **Systemd**: `comfyui-api`
- **DB**: PostgreSQL at `192.168.1.160:5433`, database `dv`, table `person`
#### Core Endpoints
#### In-Memory File Metadata Cache
To mitigate performance and network latency bottlenecks on remote/shared storage filesystems (e.g. `/mnt/zim`), the API server employs a light, thread-safe in-memory metadata cache (`_file_meta_cache`):
- **Cached Items**: File existence (`exists`) and modification timestamps (`mtime`) mapped by `filename`.
- **Cache TTL**: 5.0 seconds.
- **Idempotency & Invalidation**: Any mutation endpoints (such as crop, padding, duplication, background removal, or deletion) automatically update (`_update_cached_file_meta`) or explicitly delete (`_clear_cached_file_meta`) entries to keep cache and disk perfectly in sync.
- **Preloading Optimization**: Leverages cache queries in static indexing loops (`_sync_preloaded_images`, `_write_all_static`, `list_images`) and tracks changes to completely skip redundant static HTML rewrites if the preloaded image set is identical (`_last_preloaded_images_set`).
#### Endpoints Library
##### Core Generation & Upload
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/upload` | Upload image; optionally add to existing group (`group_id` + `skip_poses`) |
| `POST` | `/batch` | Queue multi-prompt generation job (async) |
| `GET` | `/batch/{job_id}` | Poll batch job status / per-item progress |
| `DELETE` | `/batch/{job_id}` | Cancel active batch job |
| `POST` | `/multi-ref` | Queue a generation job with multiple reference images |
| `POST` | `/refine-prompt` | Refine a generation prompt using the external uncensored chat LLM |
| `POST` | `/edit` | Edit uploaded image |
| `POST` | `/generate-scenery` | Generate scenery background based on video frame + reference image |
| `GET` | `/scenery/library` | Return all scenery images grouped by source video reference |
##### Image Editing & Canvas Expansion (Padding & Crop)
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/images/{filename}/crop` | Crop to pixel rect; `as_copy:true` crops a referenced copy instead of modifying in-place |
| `POST` | `/images/{filename}/autocrop` | Auto-trim transparent borders in-place |
| `POST` | `/images/{filename}/pad` | Expand image canvas with custom padding on each side, optional background fills (`transparent`, `black`, `white`), and optional Qwen-based Outpainting (`outpaint:true`) |
| `POST` | `/images/{filename}/rotate` | Rotate clockwise in 90° steps (lossless transpose, in-place) |
| `POST` | `/images/{filename}/invert-alpha` | Invert the alpha channel (recovers background removal errors) |
| `POST` | `/images/{filename}/duplicate` | Copy image into the same group with timestamp-based filename and `source_refs=[original]` |
| `POST` | `/restore-background/{filename}` | Flatten RGBA to opaque RGB via white composite background |
##### Background Removal
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/remove-background/{filename}` | rembg BG removal, overwrites in-place; sets `has_background=false` + refreshes static data |
| `POST` | `/remove-background-sam/{filename}` | SAM2 BG removal → `.nobg.png` sidecar |
| `POST` | `/remove-background/group/{group_id}` | Queue background removal for all group members as an async background task |
| `GET` | `/sam2/check` | Return whether SAM2 background remover is available |
##### Metadata, Custom Labels & Group Management
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/images` | List all images with DB metadata |
| `GET` | `/images/{filename}` | Serve image file |
| `POST` | `/upload` | Upload image; optionally add to existing group (`group_id` + `skip_poses`) |
| `POST` | `/batch` | Queue multi-prompt generation job (async) |
| `GET` | `/jobs/{id}` | Poll batch job status / per-item progress |
| `POST` | `/images/{filename}/set-preferred` | Move to group slot 0; triggers face extraction background task |
| `POST` | `/images/{filename}/extract-face` | Extract & save face crop via insightface (background task) |
| `POST` | `/images/{filename}/archive` | Soft-archive (hidden from default view) |
| `POST` | `/images/{filename}/unarchive` | Restore archived image |
| `POST` | `/images/{filename}/set-hidden` | Toggle studio visibility |
| `DELETE` | `/images/{filename}` | Delete image + DB record |
| `POST` | `/images/{filename}/crop` | Crop to pixel rect (in-place) |
| `POST` | `/autocrop/{filename}` | Auto-trim transparent border |
| `POST` | `/faceswap` | insightface video faceswap |
| `POST` | `/remove-background/{filename}` | rembg BG removal → `.nobg.png` sidecar |
| `POST` | `/remove-background-sam/{filename}` | SAM2 BG removal → `.nobg.png` sidecar |
| `GET` | `/wireframe/frame/{video_name}` | Extract frame at `?t=` (01) from wireframe video |
| `GET` | `/groups` | List all groups with members |
| `GET` | `/group-names` | Return map of custom group names |
| `POST` | `/group-names/{group_id}` | Save custom group name |
| `POST` | `/groups/merge` | Merge several groups into one group |
| `POST` | `/groups/extract` | Extract a file into its own solo group |
| `GET` | `/groups/{group_id}/order` | Return order of members in a group |
| `POST` | `/groups/{group_id}/order` | Set custom sort order of group members |
| `GET` | `/similar/{filename}` | Find visually similar images using database embeddings |
| `GET` | `/names` | Map `filename → person name` |
| `POST` | `/names/{filename}` | Map image file to custom character name |
| `POST` | `/images/{filename}/tags` | Set image tags list (supports tags like `ARCHIVED`, `HIDDEN`, `SOURCE`, `VISIBLE`, `LIKE`, `DISLIKE`, `21+`) |
| `POST` | `/images/{filename}/tag-action` | Add, remove, or toggle a specific tag on an image (automatically handles mutual exclusivity of `LIKE`/`DISLIKE`) |
| `POST` | `/images/{filename}/source` | Explicitly toggle marking an image as a source asset (sets/clears `is_source` and toggles the `SOURCE` tag) |
| `POST` | `/images/{filename}/estimate-21plus` | Analyze image with the WD tagger to estimate adult/sensitive rating and apply `21+` tag |
| `POST` | `/images/bulk-move` | Move a list of filenames to a specific target filmstrip (`VISIBLE`, `HIDDEN`, `ARCHIVED`, `SOURCE`) in bulk, updating tags/columns |
| `POST` | `/images/{filename}/undress` | Queue an asynchronous batch job utilizing the `UNDRESS_PROMPT` on the given reference image |
##### Visibility, Archiving & Deletion
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/images/{filename}/hidden` | Toggle studio visibility (soft-hide) |
| `POST` | `/images/{filename}/archive` | Soft-archive image (hidden from default view) |
| `POST` | `/images/{filename}/unarchive` | Restore soft-archived image |
| `DELETE` | `/images/{filename}` | Delete image file + DB record |
| `POST` | `/groups/{group_id}/archive` | Soft-archive an entire group |
| `POST` | `/groups/{group_id}/unarchive` | Restore an archived group |
| `DELETE` | `/groups/{group_id}` | Delete entire group files and DB records |
##### 2D Body Pose Tools (rtmlib / RTMPose)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/pose/check` | Report pose-estimator availability + backend (rtmlib or mediapipe) |
| `POST` | `/images/{filename}/pose` | Estimate COCO-17 body keypoints (caches descriptor for similar-search) |
| `GET` | `/pose/similar/{filename}` | Rank library images by pose similarity to this image |
| `POST` | `/pose/similar` | Rank library images by similarity to a supplied (edited) skeleton |
| `POST` | `/pose/index` | Build pose descriptors for the whole library (daemon thread) |
| `GET` | `/pose/index/status` | Poll pose-index build progress |
| `POST` | `/pose/from-wireframe` | Extract frame from a wireframe video and estimate body pose keypoints |
| `POST` | `/pose/render` | Render keypoints as OpenPose skeleton image (base64) |
| `POST` | `/generate-with-pose` | Generate image from a specific pose skeleton (avoids wireframe bleed-through) |
| `GET` | `/poses` | Load pose library from `poses.md` |
| `POST` | `/poses` | Create / update / rename a pose (`old_name` to rename) → rewrites `poses.md` |
| `DELETE` | `/poses/{name}` | Delete a pose from `poses.md` |
##### 3D Turntable & Orbit Previews
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/orbit` | Queue an orbit preview generation. Engine `depth` (fast 2.5D card parallax) or `qwen` (turntable) |
| `GET` | `/turntable/status` | Return background turntable generation state/progress summary |
| `GET` | `/turntable/status/{group_id}` | Return turntable state for a specific group |
| `POST` | `/turntable/pause` | Pause background turntable generation |
| `POST` | `/turntable/resume` | Resume background turntable generation |
| `DELETE` | `/turntable/{group_id}` | Reset turntable cache & records for group to force re-generation |
##### Face Processing (insightface)
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/faceswap` | insightface video faceswap |
| `GET` | `/faceswap/check` | Report available face enhancement backends |
| `POST` | `/images/{filename}/extract-face` | Extract & save face crop via insightface (background task) |
| `POST` | `/faces/similar` | Find groups with visually similar faces using embeddings |
| `POST` | `/faces/index` | Build face embedding index for all preferred faces in library |
| `GET` | `/faces/index/status` | Poll face-indexing progress status |
| `GET` | `/faces/{group_id}` | Check if face crop exists for group |
##### Videos & Wireframe Guides
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/videos` | List all available wireframe guide videos |
| `GET` | `/wireframe/frame/{video_name}` | Extract frame at `?t=` (01) from wireframe video |
| `GET` | `/wireframe/duration/{video_name}` | Get video duration (seconds) via ffprobe |
| `POST` | `/wireframe/trim` | Trim a wireframe video to given start/end offsets |
| `POST` | `/wireframe/frame` | Extract frame at timestamp, returns base64 PNG |
| `POST` | `/generate-video` | Stitch list of output images into a short looping MP4 video |
##### Database, Config & System Diagnostics
| Method | Path | Purpose |
|--------|------|---------|
| `GET/POST` | `/config` | Read / write `config.json` |
| `GET` | `/db/inconsistencies` | Retrieve consistency report or run a check with `?run_now=true` |
| `POST` | `/db/repair` | Run repair actions (restore, import file, assign group, permanently delete) |
| `POST` | `/db/cleanup` | Delete DB records of files that are missing on disk |
| `GET` | `/health` | API health check status |
---
@@ -99,8 +205,14 @@ prompt
│ YES → strip keywords from Qwen prompt (keeps `is_transparent` flag)
│ set negative: "deformed anatomy, watermark, logo"
├─ Canvas Expansion / Outpainting requested?
│ YES → if outpaint=True:
│ ├─ If source is transparent (RGBA): composite onto flat black background for maximum contrast.
│ ├─ Formulate dynamic prompt description: e.g. "replacing the black/white background areas"
│ └─ Append outpainting prompt instructions: "Naturally outpaint and extend..."
├─ Upload reference image(s) to ComfyUI /upload/image
│ image1 = source image
│ image1 = source image (composited RGB if outpainting transparent images)
│ image2 = wireframe pose guide frame (optional)
├─ Patch workflow graph nodes (prompt, size, seed, sampler)
@@ -217,6 +329,11 @@ Model: `buffalo_l`
| source_refs | TEXT (JSON) | Original filenames this was derived from |
| content_type | TEXT | `image` or `video` |
| faceswap_source_video | TEXT | Source video for faceswapped clips |
| face_embedding | `vector(512)` | Face recognition embedding for visually matching character faces |
| is_source | `BOOLEAN` | Whether the file is a primary reference/source asset |
| tags | TEXT (JSON) | Custom user-applied tags/labels (e.g., `['VISIBLE', 'LIKE', '21+']`) |
| pose_description | TEXT | AI-generated descriptive text of the body pose |
| pose_skeleton | TEXT | Coordinates and scoring details of the 2D COCO-17 body pose skeleton |
---
@@ -227,21 +344,78 @@ Single-page application (~5000 lines). No build step — pure HTML/CSS/JS.
**Modes**:
- **Gallery** — grid of all groups; click to open Studio
- **Studio** — filmstrip + large viewer + sidebar tabs
- **Database Consistency** (`trash.html` SPA) — dedicated panel for identifying legacy/orphaned records, untracked files, and manual restore/delete of archived assets
**Sidebar tabs**:
| Tab | Purpose |
|-----|---------|
| Generate | Camera angle picker, pose selector, wireframe guide, prompt override, fine-tune textarea, batch submit |
| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete actions, manual crop |
| Generate | Camera angle picker, pose selector (with inline edit/add/delete → `poses.md`), wireframe guide, auto-growing prompt override with history autocomplete, fine-tune textareas, multi-ref thumbnails (click to deselect), batch submit |
| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete, manual crop/expand padding (copy or in-place), rotate ±90°, No-BG / Invert-α, duplicate, Pose preview (draggable + similar-pose search) |
| Faceswap | Source face selector, target video, preview quality toggle |
| Segment | SAM2 and rembg BG removal buttons |
| Scenery | Scenery video reference for generation |
### Key Studio Features
#### Canvas Expansion & Manual Padding (Interactive Tooling)
The **Info** sidebar and dedicated toolbar provide professional, reactive manual padding controls:
- **Percent/Pixel Harmony**: Seamlessly accepts both absolute integer pixel values (e.g., `100px`) and percentage-based inputs (e.g., `10%` or `0.1`), ensuring perfect formatting and suffix preservation.
- **Quick Adjustment Presets**: Rapidly adjust margins with single-click actions for `+10%`, `+20%`, `+50%`, `+100px`, or `Reset` to clear all.
- **Uniform Locking**: Enabling the **Uniform** option synchronizes changes on any single padding field to all four sides simultaneously.
- **Intelligent Previews**: Dynamically renders real-time color-coded canvas border previews based on the selected fill type (Amber dashed for `transparent`, translucent black for `black`, and translucent white for `white`).
- **Shortcuts**: Confirm cropping or padding instantly with `Enter`, or dismiss/cancel the operation using `Escape`.
#### Reactive Layout Optimization
To prevent desyncs during image transitions, crops, canvas expansion, or 2.2x zooming:
- A `ResizeObserver` is registered on the primary studio viewer image (`lbImg`).
- Automatically triggers a layout and geometry recalculation, cleanly and immediately adjusting the background transparency checkerboard grid and padding visual overlays to match the actual visual boundaries of the contained image.
#### Multiple Filmstrips System
The filmstrip bar has been updated with five tabs that filter the group's images dynamically:
- **Group Active**: The default filmstrip containing the main/preferred assets of the group.
- **Group Variant** (`VISIBLE`): Shows all standard visible variants within the group.
- **Hidden** (`HIDDEN`): Soft-hidden variants (excluded from main cycling).
- **Source** (`SOURCE`): Displays reference images explicitly marked as source assets.
- **Archived** (`ARCHIVED`): Displays soft-archived variants.
- **Drag-and-Drop Reclassification**: Users can drag any variant thumbnail from the filmstrip and drop it directly onto any of the tab buttons to instantly update its tag/status and reassign it to that list.
#### Bulk Multi-Select & Actions
Toggling the **Multi-select** button unlocks checkbox-based selection across the filmstrip:
- Provides a live selection counter.
- Enables bulk move/reclassification buttons to dispatch batch updates to `/images/bulk-move` (e.g., `Move to Group`, `Move to Hidden`, `Move to Source`, `Move to Archived`).
#### Seamless Non-Blinking State Updates
To prevent irritating browser reload blinks or blank screen latency during intensive image editing operations:
- **Local Metadata Cloning**: Duplication, manual cropping, padding, and background removal immediately clone the original's DB metadata values locally on the client and splice the new file record into the active filmstrip list.
- **Optimistic Merge**: During periodic background server syncs (`refreshNow`), an optimistic merge algorithm compares the local client-side list and the server JSON. It preserves any freshly created local assets that haven't yet been processed/synced on the backend, maintaining perfect continuity.
#### Studio Keyboard Shortcuts Reference
| Key | Action | Description |
|-----|--------|-------------|
| `P` | Toggle Privacy Mode | Replaces the page with a discrete "AI Assist" study-chat and disguises taskbar thumbnails. |
| `C` | Toggle Checkerboard | Toggle transparent background checkerboard grid. |
| `A` | Expand/Pad Canvas | Instantly trigger the manual padding toolbar. |
| `G` / `Alt+Enter` | Trigger Generation | Clicks the main "Generate" button (when Generate tab is active). |
| `ArrowLeft` / `ArrowRight` | Nav Filmstrip | Cycle through the current list of image variants (debounced). |
| `Home` / `End` | Jump to Bounds | Instantly jump to the first or last variant in the filmstrip. |
| `Space` | Play/Pause Video | Pauses or plays a running wireframe/clip inside the viewer. |
| `H` | Toggle Hidden | Reclasses the image between standard variants and hidden list. |
| `F` | Set as Preferred | Reorders the group, placing the current image at index 0 (as preferred thumbnail). |
| `S` | Toggle Source | Mark/unmark current image as a primary reference/source asset. |
| `E` | Run 21+ Tagger | Analyze image via WD tagger and apply/remove the `21+` tag. |
| `Delete` | Soft-Archive | Move the current image into the Archive filmstrip. |
| `ArrowUp` / `ArrowDown` | Shift Order | Move/reorder the current image earlier or later in the group (debounced). |
| `2` | Move to Second | Instantly reorder current image to the 2nd position in the group. |
| `9` | Move to Last-1 | Instantly reorder current image to the second to last position. |
| `Escape` | Cancel / Dismiss | Cancel cropping, padding, close variant picker, or exit selection mode. |
**Key state variables**:
- `lbCurrentGid` — active group ID
- `lbIdx` — current filmstrip position
- `_followLatestGid` — auto-follow newly generated image
- `_followLatestFilename` — auto-follow specific new output file
- `_sbSelectedAngles` — selected camera angle names
- `_sbWireframeRef` / `_sbWireframeTime` — wireframe video + frame time
- `privacyMode` — privacy overlay toggle (key: `P`)
@@ -258,20 +432,50 @@ The studio Generate tab shows a video picker + time scrubber. On generation:
---
## Pose Tools (2D body pose)
A posenet-style toolset built on a feature-detected estimator. Preferred backend is
**rtmlib** (RTMPose, ONNX via the already-installed `onnxruntime`); **mediapipe** is a
fallback. If neither is installed the endpoints return `501` and the UI shows an install hint.
- **Install (A6000 venv):** `~/comfyui/venv/bin/pip install rtmlib`. Models (~150 MB) auto-
download to `~/.cache/rtmlib` on first use.
- **CUDA libs:** `onnxruntime-gpu` needs the venv's `nvidia-*-cu12` libs on the loader path.
`a6000-comfy/start_api.sh` adds them to `LD_LIBRARY_PATH` at launch (torch finds them via
RPATH, onnxruntime does not).
**Output format:** COCO-17 keypoints (`POSE_KEYPOINT_NAMES`) + `POSE_SKELETON` edge list,
returned in image pixels with per-joint score.
**Studio overlay (`car.html`):** the **Pose** button overlays the skeleton on the viewer
(letterbox-aware canvas). Joints are **draggable** to refine/explore a pose. Overlay toolbar:
*Reset* (re-detect) and *Similar pose* (rank the library by the current — possibly edited —
skeleton). Results render as a thumbnail strip; clicking one opens that image's group.
**Similarity model (`edit_api.py`):**
- `_pose_descriptor(keypoints)` → translation/scale-invariant vector: centered on hip midpoint
(fallback shoulders), scaled by torso length, 17×(x,y) + visibility mask. `None` if < 6 joints.
- `_pose_distance(a, b)` → weighted L2 over jointly-visible joints, taking the min of the direct
and the **left-right-mirrored** comparison so mirrored poses match.
- **Pose index:** descriptors are cached in `<output>/_data/poses_index.json`. It fills
incrementally whenever `/images/{f}/pose` runs, or in bulk via `/pose/index` (daemon thread,
batched writes every 50, progress via `/pose/index/status`).
## File Layout
```
qwen-image-edit-rapid-aio-nsfw-v23/
├── tour-comfy/
│ ├── car.html # frontend SPA
│ ├── car.html # frontend SPA (Studio Interface)
│ ├── trash.html # frontend Database Consistency Panel SPA
│ ├── edit_api.py # FastAPI backend
│ ├── database.py # PostgreSQL helpers
│ ├── watcher.py # output dir auto-processor
│ ├── config.json # output_dir, comfy_url, etc.
│ └── workflow_qwen_edit.json # ComfyUI graph template
├── a6000-comfy/ # ComfyUI install (symlinked to ~/comfyui)
├── AGENTS.md # this file
├── architecture2.svg # system architecture diagram
├── AGENTS.md # this file (System Documentation)
├── ARCHETECTURE.svg # system architecture diagram
├── backlog.md # feature backlog
└── requirements.txt # Python deps
```

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

View File

@@ -20,7 +20,7 @@ echo "Installing services: user=$SVC_USER group=$SVC_GROUP"
echo " a6k=$A6K"
echo " tour=$TOUR"
for unit in comfyui-backend comfyui-api comfyui-watcher; do
for unit in comfyui-backend comfyui-api; do
sed -e "s|__USER__|$SVC_USER|g" \
-e "s|__GROUP__|$SVC_GROUP|g" \
-e "s|__A6K__|$A6K|g" \
@@ -36,10 +36,10 @@ echo "Reloading systemd daemon..."
systemctl daemon-reload
echo "Enabling services + target..."
systemctl enable comfyui-backend.service comfyui-api.service comfyui-watcher.service comfyui.target
systemctl enable comfyui-backend.service comfyui-api.service comfyui.target
echo "Starting system..."
systemctl start comfyui.target
echo "Deployment complete."
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api comfyui-watcher"
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api"

View File

@@ -6,11 +6,24 @@ source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
source "$VENV/bin/activate"
cd "$API_DIR"
# onnxruntime-gpu (used by the body-pose estimator) needs the CUDA runtime libs that
# ship inside the venv's nvidia-*-cu12 pip packages on the loader path. torch finds
# them via RPATH, but onnxruntime does not — so put them on LD_LIBRARY_PATH here.
NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -type d 2>/dev/null | paste -sd:)
[ -n "$NV_LIBS" ] && export LD_LIBRARY_PATH="${NV_LIBS}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export COMFY_URL="http://127.0.0.1:8188"
export HOST="0.0.0.0"
export PORT="8500"
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
# This comfortably allows full-HD-ish outputs like 1920x1080.
# Override via MAX_AREA when needed.
export MAX_AREA="${MAX_AREA:-2097152}"
# @LEGACY PREVIOUS VERSION
# The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP
# output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA.
export MAX_AREA="${MAX_AREA:-1048576}"
# export MAX_AREA="${MAX_AREA:-1048576}"
exec python edit_api.py

View File

@@ -1,17 +0,0 @@
[Unit]
Description=Qwen-Image-Edit Folder Watcher (A6000)
After=comfyui-api.service
Requires=comfyui-api.service
[Service]
Type=simple
User=__USER__
Group=__GROUP__
ExecStart=/bin/bash __TOUR__/start_watcher.sh
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=comfyui.target

View File

@@ -1,7 +1,7 @@
[Unit]
Description=Qwen-Image-Edit Complete System (A6000)
Wants=comfyui-backend.service comfyui-api.service comfyui-watcher.service
After=comfyui-watcher.service
Description=Afterimage (A6000)
Wants=comfyui-backend.service comfyui-api.service
After=comfyui-backend.service comfyui-api.service
[Install]
WantedBy=multi-user.target

View File

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

Before

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -1,3 +1,123 @@
# Backlog
## Shipped (recent)
Studio / Generate UX:
- ✅ Crop reaches the bottom edge (toolbar moved to top); choose copy-vs-replace ("Save as copy")
- ✅ Paste-to-group image shows immediately (no refresh needed)
- ✅ Generated images store the prompt used (and pose name) in the DB
- ✅ Edit / add / delete poses from the Generate tab (persists to `poses.md`)
- ✅ flag beta poses in ui — beta badge + editable beta flag
- ✅ Custom prompt field auto-grows; keeps `list="promptHistory"` autocomplete
- ✅ Remove-BG persists `has_background` + refreshes static data (survives reload)
- ✅ Invert-α button (fixes wrong-segment background removal)
- ✅ zien van de geselecteerde images in de prompt — multi-ref thumbs visible; click to deselect
- ✅ Landing-page group merge syncs immediately (synchronous static write)
- ✅ Rotate ±90° in the studio
Pose tools (rtmlib / RTMPose, 2D):
- ✅ Pose skeleton preview overlay in studio
- ✅ Draggable joints to refine/explore a pose
- ✅ Find similar poses across the library (translation/scale/mirror-invariant; `poses_index.json`)
- ✅ Gesture presets in pose toolbar (T-pose, wave, arms raised, hands on hips, turn ±45°)
- ✅ "Generate ▸" in pose toolbar — renders skeleton as OpenPose image → Qwen image2 guide → new generation
- ✅ "Orbit all" button in Generate tab selects all 6 absolute camera angles at once
Scenery:
- ✅ source_refs now stores all references: person image + video name + extra ref image
- ✅ /scenery/library endpoint — all scenery grouped by source video
- ✅ "Previous scenery" panel in Scenery tab — browse & reuse scenery from any group, grouped by video
## Open
- remove "clean-db" button from UI- studio. But perhaps check daily for inconsistencies and show identified issues in a "Archive/Trash" view perhaps seperate html?
- ✅ OUTPAINT feature/ crop/ pad call qwen
- the pad/crop i think should allow also percentage values, since we looking in scaled images.
- valid multi-input way to facilite the 2.5/3d orbit
- ✅ Refine a POSE, so we have the ref (source) image + pose prompt. use the tour GPU + api to refine the pose with user instruction. Then use the source image + updated prompt to generate a new image.
- introduce in the custom padding features + auto crop afterwards
- ✅ in car.html - studio view we have a action to pad the image we also have this feature in the "Generate" tab.
- ✅ add a checkbox (or similar) to instruct the image-model to outpaint the padded area. Also implement the outpainting functionality in edit_api.py, we can instruct qwen-image. We can have the same functionality in the "Generate" tab when no prompt is given but a padding is given.
- ✅ we have a bug that when pressing the ui-action "duplicate" and "pad" the browser needs to refresh before we see the result. Other actions like generating from pose do not hae this bug, fix the bug and boy scout adjecnt actions if they are missing the required technical follow up. Fix the bug for the features.
- ✅ In the "Generate" tab, the CUSTOM prompt is shown twice, above POSES and statically always in view in the right bottom. Remove the statically CUSTOM prompt from the UI.
- The Generate button is always visible in the "Generate" tab, however we cannot jump to it via keyboard. Add a keyboard shortcut to jump to the Generate button "G" or simialler.
- ✅ remove the "set prompt"
- after pose generation, sometimes small black area's occur in the subject. ( presumably the _apply_transparency_black_bg addition)
- ✅ studio view - orbit tab - view sometimes seems accelerated, liek the tiems is set twice. And most of all a step in the turn seems in in the wrong direction. maybe its an image-ordering bug, but i think its because the image-model generated the step 240deg into the wrong direction
- Improve the UI/UX for the end-user experience. Where effort is low and impact is high.
- add an action + key-bind to move a the current selected image in the filmstrip to the end of the filmstrip. ( so also do the actual reordering of the image )
-
- animate limbs along keyframe curves (true motion)
- 3D-aware rotation / "circling around" with depth estimation
- camera-orbit batch with consistent lighting across views
- ✅ extract a frame from a clip (scenery)
- faceswap defaults nothing enabled, see jobs even when refresh
- rating based pose, thumbs up/down find good/bad poses easier, (pairs well with the new pose index — could weight similar-pose results by rating)
- ✅ after orbit generation, the images cannot be duplicated, set preferred, archived or otherwise modified server response "{"detail":"Not Found"}"
- after orbit generation, dont generate the mp4, but do show the orbit orbit-full-img-wrap and render the alpha images 1fps ourselves.
- allow re-ordering of the images in the orbit generated frames
- ✅sometimes the UI renders two orbits in the same orbitFullViewer, must be a timer not being cleared in the ui
- The UI wants to car.html:2817 GET file:///mnt/zim/tour-comfy/output/_turntable/up_34e48ffb/turntable.jpg?t=1782562827857 net::ERR_FILE_NOT_FOUND, often want to show the wireframe - pose
- ✅ Fixed: statically serve turntable and orbit preview files from filesystem, not via server (avoid GET /output/... 304 Not Modified log spam)
- Check face similarity in a group of images.
- ✅ Fixed: implement a tag-system for images, will be used to filter, group, sort and search images. Obvious tags are VISIBLE, ARCHIVED, LIKE, DISLIKE, RATED, GROUP_ANCHOR, GROUP_MEMBER, BACKGROUND etc..
- ✅ Fixed: mark image as source (with dedicated 'S' keybind)
- ✅ Fixed: introduce multiple filmstrips (Group, Hidden, Source, Archived with drag-and-drop & bulk selection actions).
- samengestelde outpaint featurue lijkt niet goed te werken, but individual steps work perfect.
- regenerate a single orbit - pose
- when generating a known pose, we should know roughly the image ratio. Make a pose-table, when a pose is generated store the wireframe and meta data
- we can rate an image if its OVER21, manually now in the UI. this is fine, however we want to backfill this TAG for evey image
- backfill, people-count, 21+, IS_COMPLETE, RESTRAINT
- ✅ the wireframe / videos / sub-clips worden niet direct weergegeven
- make the faceswap preview mode fixed size instead of 50% we dont know how big the original clip is. just 470p or something.
- include 2 filter(s) in the filmstrip for VIDEO and ORBIT only show them if the sub-set is not empty.
- add a 2nd visible filmstrip to easy move items from one to another strip in the UI
- on the landing page we now just play all images of an group. However if the orbit generaition is complete. I want to circulate those images , (this basically is a new filmstrip feature)) so we set the orbit in a filmstrip by default, but can modify it. Also we want the face to be visible in the landing page and the most prominent (most 3d alike full body with femine descriptors)) image
- still looking for that killer-feminine shot, one image that would show most characteristics of a nude female including eyes.
- scrolling past the Scne - STANDALONE / UPLOADED IMAGES still is unpleasant. I think all images get completly resized and 100x recalculated when moving the scrollbar there.
- introduce bulk select in trash.html
- tab "SAM" checkerboard icon to studio-view overlay
- in the landing page hide SOURCE images
- als het input bestand real is, dan gaat echt elke opvolging perfect. supe plaatjes.. (3 kwart maakt de opvolgende plaatjes ook altijd engaging.. top down (vanwege ruimte))
- When open the app via the launcher, make it privacy enabled by default, so the privacy screen will appear.
- When open a group by default hide orbit images, videos
- Do not show videos in the landingpage image-loop
- Make - html per group and html for the landing page. Decouple the landingpage from the studio and studio is hydrated available per group
- update svg
- Extract the heavy model API, so we can unit-test it. ( and we can stand-alone disable it ) and doesnt require the entire 41gb model to load into memory for a simple backend update
- Save pose-prompt history + refine + reverse engineer + Scenery prompt in database. create a new db table called prompt, add the type of prompt for exmaple refine or scene to the record and all relevant information used to recontruct the prompt input. so also include a jsonb for metadata/tags etc.
- In order to decouple the model-engine (by design it can be turned off) and the backend (by design it can be turned off)
- in the UI right top make two dots indicating the model-engine, backend is online and if its (actively processing a task), use the healthchecks of the services to check realtime status.
- in case the Model-engine is offline, we want to disable the UI-actions that require the model-engine, such as pose generation,
- in case the backend is offline, we want to disable the UI-actions that require the backend, such as duplicate, pad, crop, etc..
Start by implmenting the mechanics of the status leds in the UI. and disable one or two features that require the model-engine or the backend to be online. We complete the task after review
- in order to decouple the landingpage/dashboard from the studio view (editor) we are going to generate group "shoot" specific html pages. Start by just generate the studio portion of car.html into the group specifc html pages. It does not need to be synchronous, but when data of the group is changing we should update the group specifc data json file too, we copy the goup files to the output folder next to car.html
- in /mnt/zim/tour-comfy/output/_turntable we have data regarding orbit generations. We want to store the orbit data in the database, start by adding the corresponding tags and meta info to the images (currently stored in the person table (but actually are images with metadata))
- in the ui - studio view we have an orbit filter. Currently the filter is filtering too many files, also references of orbit files are being marked orbit. When an orbit file is used, the metadata should not include the orbit tag.
- pose-prompt has at least three parts, [scenery ,pose, art-form] allow in the ui to compound the prompt with these, scene default = "You are in a black void" art-form= "photo-realistic keep identical person from reference" also allow inherit scenery to as option.
## refine
#file:///mnt/zim/tour-comfy/output/shoot_cg_7ec17537.html
- when refresh page, we lose track of current jobs running.
- generating poses themself should use the adviced dimensions rather than the base image reference.
- introduce a like/dislike base system for images/group to determine the sort order (and more), would also dislike the pose
- ✅ creating scenery should keep both video-frame + ref images as references, we only see 1 image now. ✅ fixed
- gesture ideas for future (require ControlNet / OpenPose conditioning):
- add expand preset for sqaure image
## notes
pose bestaat gewoon uit meerdere delen, camera, scenery + addition
poses: hyper realistic, lighting, detailled skin etc.. see last generations for better results.
focus op head + sam2.hyria seg + mesh (3D / mesh — pose editor is currently 2D only)
sam2 saifrail, connected pixels from mid? als invertor?
poses assignment bij de camera positie generaties
create disk 128gb 70b dolphin uncencored op server.
- Use own insight for further refinement of these improvements.
fix queen.mp
moet gegenereerd worden door de htmlbuilder, niet via de webserver
images.json
@@ -7,22 +127,50 @@ groups.json
config
videos
flag beta poses in ui
# generieke stappen
1) 3ref image HQ detail
2) 3ref image MERGE
3) 3ref straight on- head-on - looking viewer
extract a frame from a clip (scenery)
faceswap defaults nothing enabled, see jobs even when refresh
ok cool,
Let do some work in the "Info" tab.
Pose information
<usefull info is here>
GAZE: -
BODY: -
SUBJECTS: -
TRACKING
pose bestaat gewoon uit meerdere delen, camera, scenery + addition
1) we basically always see GAZE: - BODY: - etc.. (perhaps we only mis it passing thru in the frontend)
2) We would like to see information about the face, completeness of the anatomy (perhaps an indication on how much to outpaint to make it anatomically correct), camera/view oriention. Objects found in the image. We use this information later on to refine the prompts
3) we can move some actions (or duplicate) the actions into the "studioAngleBar". It already got included the Expand canvas icon. we can ad the icons for Pose, Duplicate, Crop...(auto-crop) and Crop
4) add a new video filter, and hide video by default in the Group Active
-- feel free to furhter optimize the studio view "info" tab
focus op head + sam2.hyria seg + mesh
zien van de geselecteerde images in de prompt in studio view
sam2 saifrail, connected pixels from mid? als invertor?
poses assignment bij de camera positie generaties
poses: hyper realistic, lighting, detailled skin etc.. see last generations for better results.
check 7b uncensored for automation
so we have a postgres-db, the frontend car.html and the backend edit_api.py
1) for every image we have an entry in the database table person, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. .
In the app we looking at groups of images, so also keep track of the group, this include the additional functional features we show in the UI for that group with the images it includes. Keep the data up-to-date in the backend when data changes, also update the referring data files.
2) introduce a rating feature. to thumps up or thumbs down an image. When an image gets athamps up or thumbs down add a tag in the db and show it in the UI. Also the default filter in "Group Active" should filter out the images with a negative rating, but also include a filter that would show only images with a positive rating. The rating should also cascade (calculated? perhaps) into the poses. So images with pose that have good rating will define the rating of a pose, same for the image group (use a clever normalized formula ) over the positive and negative thumbs to compare it with another group
3) introduce (?cosine) similarity variation indexes in a group. Also for the faces in a group. To keep track of authenticity of a person in a group
3) In the Design tab we will refine new poses. Often we want to reverse ingineer an existing image. Find a way/add a llm-model or reuse the VID or wd tagger or w/e to get something similar from an image we that we provide to qwen to generate images. We could also use this feature in the Info tab to suggest an updated description of the image.
4) Given a POSE, we want to estimate the image-size-ratio of the corresponding end-pose-skeleton to estimate if we need to pad the image before the actual image generation, try to see if we can fit this feature in somewhere, for now we will only do a suggestion based on that information.
5) make the faceswap preview mode fixed size instead of 50% we dont know how big the original clip is. just 470p or something standard.
6) on the landing page we now just play all images of an group. However if the orbit generaition is complete. I want to circulate (semi-video-view) those images (small in a corner or something of that group card) (and filtered out of the rest of the circulating images.) (same for the face feature to show that static on the group card.)
create disk 128gb 70b dolphin uncencored op server.
ok great. shoot-specifc html pages. have the orbit tab showing orbit of other shoots.
for now the orbit tab should only should orbits of the current group, which we can in the future add multiple per group.
So for now just filter the shoot specific pages orbits per group.
Then when we select multipel in the dashboard navigate tot he studio-view and show the selected orbit-groups in the orbit tab.
But when we sleect a single group in the dashboard navigate to the group specific html page and only show the orbit(s) of that group.
the the status legs are great, they only hide the upload field in the landing page. and the hamburger/toolba in the specific group pages.
What we mis in the shoot specific html page is hte privacy feature. we need to integrate that lock icon there.
If we lock the shoot specific html page, it should amke the entire app in privacy mode and set the other opened tabs in that state as well.
then in the info tab we can perform the "Suggest Description" action.
introduce a new db field in the "person" table called "description". show both description and prompt in the info tab. and both allow refinement/ creation (if empty)
however the prompt should be directed towards the pose of the image and be replicated over other actors (so no skin color, hair color). The description should be more broad to also include the scenery and character details of the actor(s) like skin color, hair color.
furhtermore, lets clean up the actions section in the "Info" tab

117
ph_downloader.py Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Pornhub Video Downloader Side-Script
Uses yt-dlp under the hood for highly robust and fast downloads.
"""
import sys
import os
import subprocess
# Ensure yt-dlp and curl-cffi are installed
try:
import yt_dlp
import curl_cffi
except ImportError:
print("[-] yt-dlp or curl-cffi is not fully installed in this Python environment.")
print("[*] Attempting to install yt-dlp with curl-cffi automatically...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp[default,curl-cffi]"])
import yt_dlp
import curl_cffi
print("[+] Successfully installed yt-dlp and curl-cffi!")
except Exception as e:
print(f"[!] Failed to install dependencies automatically: {e}")
print("[!] Please install manually with: pip install \"yt-dlp[default,curl-cffi]\"")
sys.exit(1)
import argparse
def download_video(url, output_dir=".", quality="best"):
print(f"[*] Initializing download for: {url}")
print(f"[*] Output directory: {os.path.abspath(output_dir)}")
if not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# Format selector configuration
# 'best' is the safest format because it downloads pre-merged streams and doesn't strictly require ffmpeg.
# 'bestvideo+bestaudio/best' will download separate video/audio streams and merge them if ffmpeg is present.
format_selector = 'best'
if quality != 'best':
if quality.endswith('p'):
height = quality[:-1]
format_selector = f'bestvideo[height<={height}]+bestaudio/best[height<={height}]'
else:
format_selector = quality
ydl_opts = {
'format': format_selector,
'outtmpl': os.path.join(output_dir, '%(title)s [%(id)s].%(ext)s'),
'noplaylist': True,
# Pornhub has age gates; standard user-agent and headers are handled by yt-dlp automatically,
# but let's add some basic robust options.
'ignoreerrors': False,
'logtostderr': False,
'quiet': False,
'no_warnings': False,
'nocheckcertificate': True,
}
# Setup browser impersonation to bypass Cloudflare/TLS fingerprinting blocks (e.g. HTTP 403 Forbidden)
try:
from yt_dlp.networking.impersonate import ImpersonateTarget
ydl_opts['impersonate'] = ImpersonateTarget.from_str('chrome')
except (ImportError, AttributeError):
ydl_opts['impersonate'] = 'chrome'
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
print("\n[+] Download completed successfully!")
print(f"[+] File saved to: {filename}")
return True
except Exception as e:
print(f"\n[!] Error downloading video: {e}")
# Try a safe fallback to 'best' format if we tried a complex format query
if format_selector != 'best':
print("[*] Retrying with fallback quality ('best')...")
ydl_opts['format'] = 'best'
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
print("\n[+] Download completed successfully via fallback!")
print(f"[+] File saved to: {filename}")
return True
except Exception as fe:
print(f"[!] Fallback download also failed: {fe}")
return False
def main():
parser = argparse.ArgumentParser(description="Pornhub / General Video Downloader")
parser.add_argument("url", nargs="?", help="URL of the video to download")
parser.add_argument("-o", "--output", default="/mnt/zim/tour-comfy/wireframe/", help="Output directory path (default: current directory)")
parser.add_argument("-q", "--quality", default="720p", help="Video quality (e.g. best, 1080p, 720p, 480p) (default: 720p)")
args = parser.parse_args()
url = args.url
if not url:
# Interactive mode
try:
url = input("Enter video URL (e.g., Pornhub link): ").strip()
except (KeyboardInterrupt, EOFError):
print("\n[-] Cancelled.")
sys.exit(0)
if not url:
print("[!] No URL provided. Exiting.")
sys.exit(1)
success = download_video(url, args.output, args.quality)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View File

@@ -13,10 +13,14 @@ set -euo pipefail
REMOTE="tour@192.168.1.160"
REMOTE_DIR="/media/tour/NVME0/llm"
LLAMA_SERVER="$REMOTE_DIR/llama.cpp/build/bin/llama-server"
MODEL="$REMOTE_DIR/models/cognitivecomputations_Dolphin3.0-Mistral-24B-Q4_K_M.gguf"
# Q8_0: ~24GB — fits entirely on the MI50 32GB, better quality than Q4_K_M
# Override: MODEL_FILE=cognitivecomputations_Dolphin3.0-Mistral-24B-Q4_K_M.gguf ./deploy_pose_llm.sh deploy
MODEL_FILE="${MODEL_FILE:-cognitivecomputations_Dolphin3.0-Mistral-24B-Q8_0.gguf}"
MODEL_URL="https://huggingface.co/bartowski/cognitivecomputations_Dolphin3.0-Mistral-24B-GGUF/resolve/main/${MODEL_FILE}"
MODEL="$REMOTE_DIR/models/$MODEL_FILE"
PORT="${PORT:-8001}"
CTX="${CTX:-4096}"
NGL="${NGL:-99}" # GPU layers: 99 = all on GPU
CTX="${CTX:-32768}" # Q8 has headroom; bump context from 4096
NGL="${NGL:-99}" # all layers on GPU (24GB < 32GB VRAM)
ACTION="${1:-deploy}"
@@ -42,8 +46,23 @@ stop_one() {
"
}
download_model() {
print_header "Downloading $MODEL_FILE to $REMOTE"
ssh "$REMOTE" "
set -euo pipefail
if [ -f '$MODEL' ]; then
echo '==> Already exists:'; ls -lh '$MODEL'; exit 0
fi
echo '==> Downloading ~24GB — this will take a while...'
mkdir -p '$REMOTE_DIR/models'
wget -c --show-progress -O '${MODEL}.tmp' '$MODEL_URL'
mv '${MODEL}.tmp' '$MODEL'
echo '==> Done:'; ls -lh '$MODEL'
"
}
deploy_one() {
print_header "Deploying llama-server on $REMOTE (model: Q4_K_M, ngl=$NGL)"
print_header "Deploying llama-server on $REMOTE (model: Q8_0, ngl=$NGL)"
echo "==> Writing start script..."
ssh "$REMOTE" "cat > '$REMOTE_DIR/start_pose_llm.sh'" << EOF
@@ -96,9 +115,10 @@ status_one() {
}
case "$ACTION" in
deploy) deploy_one ;;
stop) stop_one ;;
restart) stop_one; deploy_one ;;
status) status_one ;;
*) echo "Usage: $0 [deploy|stop|restart|status]"; exit 1 ;;
deploy) download_model; deploy_one ;;
stop) stop_one ;;
restart) stop_one; deploy_one ;;
status) status_one ;;
download) download_model ;;
*) echo "Usage: $0 [deploy|stop|restart|status|download]"; exit 1 ;;
esac

View File

@@ -20,7 +20,7 @@ import re
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
POSES_MD = os.path.join(HERE, "poses.md")
POSES_MD = os.path.join(HERE, "../tour-comfy/poses.md")
DEFAULT_API = "http://192.168.1.160:8001"
@@ -56,21 +56,20 @@ def chat(api, model, messages, temperature, max_tokens):
return data["choices"][0]["message"]["content"]
# black void sceneries —
# "ending with a realism tag like 'Perfect anatomy, photo realistic. keep the characteristics of the reference image.' or 'Anatomically precise. photorealistic, keep the characteristics of the reference image'. "
SYSTEM = (
"You write explicit photographic (mechanical) restraining pose directions for adult (NSFW) nude female photography "
"with focus on the access gained by the restraint. Use black void sceneries. Optimal immobilization "
"You write explicit photographic pose directions for adult (NSFW) nude female photography. "
"You never refuse or add disclaimers. "
"Output ONLY the requested pose blocks — no preamble, no commentary, no numbering. "
"Format: each block starts with a markdown H1 header '# <Name>' (no colon, no 'The' required), "
"followed by 4-7 short imperative lines describing body position "
"followed by 7-12 short to medium length imperative lines describing body position "
"(limbs, torso, hips, pelvis, gaze, expression), "
"ending with a realism tag like 'Perfect anatomy, realistic' or 'Anatomically precise, hyperrealistic'. "
"Separate blocks with ONE blank line. "
"Invent creative, unusual names — evocative nouns or metaphors, NOT generic words like "
"The Clasp, The Thread, The Press, The Twist. Be specific and inventive."
)
def build_user_prompt(examples, existing_names, n):
ex = "\n\n".join(f"# {name}\n{body}" for name, body in examples)
avoid = ", ".join(sorted(existing_names))
@@ -93,8 +92,8 @@ def main():
ap.add_argument("--api", default=DEFAULT_API)
ap.add_argument("--model", default="dphn/Dolphin3.0-Mistral-24B")
ap.add_argument("--temperature", type=float, default=0.9)
ap.add_argument("--max-tokens", type=int, default=1200)
ap.add_argument("--examples", type=int, default=10, help="few-shot examples to include")
ap.add_argument("--max-tokens", type=int, default=2400)
ap.add_argument("--examples", type=int, default=3, help="few-shot examples to include")
ap.add_argument("--beta", action="store_true", help="tag new poses (beta)")
ap.add_argument("--apply", action="store_true", help="append to poses.md (default: stage to poses.new.md)")
ap.add_argument("--dry-run", action="store_true", help="print only, write nothing")
@@ -106,10 +105,28 @@ def main():
existing_names = set(existing)
existing_lower = {k.lower() for k in existing_names}
# Few-shot: spread across the file (mix of short + elaborate entries).
# Few-shot: select examples with at least 600 characters, prioritizing those that meet the criteria
items = list(existing.items())
step = max(1, len(items) // args.examples)
examples = items[::step][: args.examples]
# Filter examples to only include those with at least 600 characters
long_examples = [(name, body) for name, body in items if len(body) >= 600]
# If we don't have enough long examples, include all examples but prioritize long ones
if len(long_examples) < args.examples and len(items) > 0:
print(f"Warning: Only {len(long_examples)} examples with 600+ characters found, using all examples")
# Include all examples but sort by length (longest first) to prioritize quality
sorted_items = sorted(items, key=lambda x: len(x[1]), reverse=True)
examples = sorted_items[:args.examples]
else:
# Use only long examples and spread them out
if long_examples:
step = max(1, len(long_examples) // args.examples)
examples = long_examples[::step][:args.examples]
else:
# If no long examples exist, use all examples but warn
print("Warning: No examples with 600+ characters found")
step = max(1, len(items) // args.examples)
examples = items[::step][:args.examples]
user = build_user_prompt(examples, existing_names, args.n)
raw = chat(

View File

@@ -7,7 +7,8 @@ via ROCm 5.7 + torch 2.3.1, mirroring the joycaption service pattern.
Model fits in fp16 inside the 32GB VRAM (12B ceiling). Override with env MODEL_ID.
Endpoints:
POST /v1/chat/completions OpenAI-compatible chat (used by gen_poses.py)
POST /v1/chat/completions OpenAI-compatible chat, supports stream=true (SSE)
POST /v1/completions legacy text completion, supports stream=true (SSE)
GET /v1/models list the loaded model
GET /health health check + GPU info
@@ -18,8 +19,12 @@ Env:
"""
import asyncio
import json
import os
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
import subprocess
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
@@ -27,8 +32,10 @@ from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
torch.set_num_threads(1)
torch.set_num_interop_threads(1)
@@ -64,7 +71,7 @@ async def lifespan(app: FastAPI):
# Boost GPU to high performance mode (avoids power-saving clock throttle)
subprocess.run(["/opt/rocm/bin/rocm-smi", "--setperflevel", "high"], capture_output=True)
print(f"Loading model {MODEL_ID} (gpu≤{MAX_GPU_MEM} cpu≤{MAX_CPU_MEM})...", flush=True)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, local_files_only=True)
if not tokenizer.chat_template:
print("No chat_template found — applying Mistral [INST] fallback.", flush=True)
tokenizer.chat_template = _MISTRAL_TEMPLATE
@@ -73,6 +80,7 @@ async def lifespan(app: FastAPI):
torch_dtype=torch.float16,
device_map="auto",
max_memory={0: MAX_GPU_MEM, "cpu": MAX_CPU_MEM},
local_files_only=True,
)
model.eval()
state["tokenizer"] = tokenizer
@@ -89,6 +97,13 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Uncensored Chat LLM API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
def _run_chat(messages, max_tokens, temperature, top_p, stop):
tokenizer = state["tokenizer"]
@@ -142,6 +157,75 @@ async def run_chat(*args):
return await asyncio.get_running_loop().run_in_executor(gpu_executor, _run_chat, *args)
def _iter_stream(messages, max_tokens, temperature, top_p, stop):
"""Generator that yields SSE lines for a streaming chat completion."""
tokenizer = state["tokenizer"]
model = state["model"]
cid = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created = int(time.time())
input_ids = tokenizer.apply_chat_template(
messages, add_generation_prompt=True, return_tensors="pt"
).to(model.device)
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
do_sample = temperature is not None and temperature > 0
gen_kwargs = dict(
input_ids=input_ids,
max_new_tokens=max_tokens,
do_sample=do_sample,
use_cache=True,
pad_token_id=tokenizer.eos_token_id,
streamer=streamer,
)
if do_sample:
gen_kwargs["temperature"] = temperature
gen_kwargs["top_p"] = top_p
stops = []
if stop:
stops = [stop] if isinstance(stop, str) else list(stop)
def _generate():
with torch.inference_mode():
model.generate(**gen_kwargs)
t = threading.Thread(target=_generate, daemon=True)
t.start()
buffer = ""
for token in streamer:
buffer += token
# Check stop sequences across the accumulated buffer
if stops:
cut = min((buffer.find(s) for s in stops if s and s in buffer), default=-1)
if cut != -1:
token = buffer[len(buffer) - len(token):cut] if cut < len(buffer) else ""
chunk = {
"id": cid, "object": "chat.completion.chunk", "created": created,
"model": MODEL_ID,
"choices": [{"index": 0, "delta": {"content": token}, "finish_reason": None}],
}
yield f"data: {json.dumps(chunk)}\n\n"
break
chunk = {
"id": cid, "object": "chat.completion.chunk", "created": created,
"model": MODEL_ID,
"choices": [{"index": 0, "delta": {"content": token}, "finish_reason": None}],
}
yield f"data: {json.dumps(chunk)}\n\n"
t.join()
done = {
"id": cid, "object": "chat.completion.chunk", "created": created,
"model": MODEL_ID,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
yield f"data: {json.dumps(done)}\n\n"
yield "data: [DONE]\n\n"
del input_ids
class ChatMessage(BaseModel):
role: str
content: str
@@ -154,6 +238,17 @@ class ChatRequest(BaseModel):
temperature: float = 0.8
top_p: float = 0.95
stop: list[str] | str | None = None
stream: bool = False
class CompletionRequest(BaseModel):
model: str | None = None
prompt: str
max_tokens: int = 512
temperature: float = 0.8
top_p: float = 0.95
stop: list[str] | str | None = None
stream: bool = False
@app.post("/v1/chat/completions")
@@ -161,6 +256,11 @@ async def chat_completions(req: ChatRequest):
if not req.messages:
raise HTTPException(400, "messages must not be empty")
msgs = [{"role": m.role, "content": m.content} for m in req.messages]
if req.stream:
gen = _iter_stream(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
return StreamingResponse(gen, media_type="text/event-stream")
r = await run_chat(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
@@ -183,9 +283,61 @@ async def chat_completions(req: ChatRequest):
}
@app.post("/v1/completions")
async def completions(req: CompletionRequest):
"""Legacy text completion endpoint — wraps prompt as a user message."""
msgs = [{"role": "user", "content": req.prompt}]
if req.stream:
cid = f"cmpl-{uuid.uuid4().hex[:24]}"
created = int(time.time())
def _legacy_stream():
for sse in _iter_stream(msgs, req.max_tokens, req.temperature, req.top_p, req.stop):
# Re-wrap chat chunk format as legacy completion chunk
if sse.startswith("data: [DONE]"):
yield sse
return
if sse.startswith("data: "):
chat_chunk = json.loads(sse[6:])
delta = chat_chunk["choices"][0]["delta"].get("content", "")
finish = chat_chunk["choices"][0]["finish_reason"]
chunk = {
"id": cid, "object": "text_completion", "created": created,
"model": MODEL_ID,
"choices": [{"index": 0, "text": delta, "finish_reason": finish}],
}
yield f"data: {json.dumps(chunk)}\n\n"
return StreamingResponse(_legacy_stream(), media_type="text/event-stream")
r = await run_chat(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
return {
"id": f"cmpl-{uuid.uuid4().hex[:24]}",
"object": "text_completion",
"created": int(time.time()),
"model": MODEL_ID,
"choices": [
{
"index": 0,
"text": r["text"],
"finish_reason": r["finish_reason"],
}
],
"usage": {
"prompt_tokens": r["prompt_tokens"],
"completion_tokens": r["completion_tokens"],
"total_tokens": r["prompt_tokens"] + r["completion_tokens"],
},
}
@app.get("/v1/models")
async def list_models():
return {"object": "list", "data": [{"id": MODEL_ID, "object": "model"}]}
return {
"object": "list",
"data": [{"id": MODEL_ID, "object": "model", "owned_by": "local", "permission": []}],
}
@app.get("/health")

22
studio.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Launch the studio UI in a clean Chrome profile (no stale cache)
TMPDIR=$(mktemp -d /tmp/chrome-studio-XXXXXX)
trap "rm -rf '$TMPDIR'" EXIT
DRI_PRIME=pci-0000_02_00_0 \
google-chrome \
--disable-web-security \
--allow-file-access-from-files \
--user-data-dir="$TMPDIR" \
--disable-dev-shm-usage \
--no-first-run \
--no-default-browser-check \
--disable-infobars \
--test-type \
--ozone-platform=x11 \
--disable-vulkan \
--use-gl=desktop \
--log-level=3 \
--silent-debugger-extension-api \
--app="file:///mnt/zim/tour-comfy/output/car.html" \
2>/dev/null

View File

@@ -1,77 +0,0 @@
# The kneeler (beta):
A realistic artistic nude female.
The model kneels on all fours with hands planted firmly under the shoulders and knees directly under the hips. One arm and the opposite leg are lifted completely off the ground, creating a powerful diagonal stretch across the body. Hips are lowered toward the ground. The composition emphasizes the muscular tension in the lifted limbs and the continuous curved lines of the arched back and torso. Strength and dynamic energy radiate from the pose. Studio lighting, anatomically precise, no background, plain neutral backdrop, high detail, professional figure reference photography.
# The Kneeling (Dynamic):
kneeling on all fours, hands under shoulders, knees under hips.
one arm and opposite leg slightly lifted off the ground, creating diagonal tension.
hips lowered toward the ground, back forming a smooth curved arc.
muscular definition in lifted limbs, continuous curved lines of torso.
Looking directly into camera. Strong, dynamic.
Perfect anatomy, realistic
# The Sphinx(beta):
lying on stomach, propped up on forearms.
elbows under shoulders, chest lifted, back arched deeply.
legs extended straight behind, toes pointed.
head lowered, face turned down toward the ground, chin tucked.
Looking down, not at camera. Regal, poised.
Perfect anatomy, realistic
# The Ascension (beta):
lying on back.
arms extended overhead, hands clasped or reaching past the head.
legs lifted straight up together, fully extended toward ceiling.
lower back pressed flat to the ground, shoulders relaxed.
vertical line from shoulders through heels.
Looking directly into camera. Weightless, transcendent.
Perfect anatomy, realistic
# The Sickle (beta):
lying on back, legs lifted and bent.
knees drawing toward the chest, then extending and lowering toward the head.
feet arched, toes pointing toward the ground behind the head.
arms wrapped around the thighs or extended to the sides.
body folded tightly, spine compressed.
Looking at the knees, then into camera. Curved, hooked.
Perfect anatomy, realistic
---
# The Hogtie:
lying on stomach, wrists bound behind the back.
ankles bound together, drawn toward the bound wrists.
body arched backward, chest and thighs lifted slightly off the ground.
head turned to the side, cheek on the ground.
Looking directly into camera. Contorted, helpless.
Perfect anatomy, realistic
---
# The Spread Eagle (Restrained):
lying on back, arms extended straight out to the sides, wrists bound to implied anchors.
legs spread wide, ankles bound to implied anchors.
back flat on the ground, hips centered.
head tilted back, throat exposed.
Looking upward, not at camera. Vulnerable, displayed.
Perfect anatomy, realistic
# Single Steel Bar — Behind Knees, Through Elbows, Prone Display
You are in black void, folded over a single solid steel bar. One polished steel rod passing behind your knees and through your bent elbows, holding your legs bent and your arms pinned behind your back simultaneously.
Your body draped face-down over this single bar, chest hanging, posterior elevated. Ankles cuffed together below the bar.
Wrists cuffed together above the bar. One bar, two cuffs. Your body folded into a tight prone package, dorsal plane fully presented. Floating examination light above.
Eyes looking sideways at camera, keeping your facial characteristics as reference photo. One bar, full posterior examination access. Realistic, anatomically precise.
# Single Steel Pillory — Standing Bent, Neck and Wrists
You are bent forward at the waist in black void, locked into a single solid steel pillory. One heavy polished steel board, floating horizontally. Three holes: your neck locked in the center, your wrists locked to either side. The board holds you at hip height, torso parallel to the void floor. Your ankles cuffed together by a solid steel bar. No stand, no support — the pillory floats and you are fixed within it. Posterior fully exposed behind you. Floating examination light. Eyes looking forward at camera from beneath the board, keeping your facial characteristics as reference photo. One board, total bent immobilization. Realistic, anatomically precise.
# Single Steel Hook — Suspended by the Waist, Arms and Legs Free
You are suspended in black void from a single solid steel hook. One polished steel curve descending from the darkness above, its blunt tip passing under a steel band cinched around your waist. Your body hangs horizontally, face down, arms and legs dangling free but separated by gravity. Ankles weighted by small steel cuffs, pulling your legs slightly apart. Wrists weighted by matching cuffs, pulling your arms forward. No other support. You hang from one hook, one band. Body suspended in full extension, all surfaces accessible. Floating examination light beneath you. Eyes looking at camera from your suspended position, keeping your facial characteristics as reference photo. One hook, full circumferential examination access. Realistic, anatomically precise.
# Single Steel Ring — Wrists and Ankles Behind, Kneeling Open
You are kneeling in black void. A single solid steel ring, polished and seamless, floating behind your back. Your wrists locked into the ring at the top. Your ankles locked into the ring at the bottom, legs spread wide by its diameter. No other material. One ring, four limbs converged behind you. Your body folded forward, chest toward the void floor, posterior elevated and fully exposed. A floating examination light illuminates your open dorsal plane. Full access for examination. Eyes looking back at camera over your shoulder, keeping your facial characteristics as reference photo. One ring, absolute exposure, minimal material. Realistic, anatomically precise.

View File

@@ -1,64 +0,0 @@
#!/bin/bash
# One-time (idempotent) host setup for the Qwen-Image-Edit service.
# Runs as the service user (NO sudo). Safe to re-run: existing pieces are skipped.
#
# Builds, under the project BASE (the parent of this api/ dir):
# venv/ torch 2.3.1+rocm5.7 + ComfyUI deps (gfx906 / ROCm 5.7)
# ComfyUI/ pinned to v0.3.77 + ComfyUI-GGUF custom node
# ComfyUI/models/{unet,text_encoders,vae}/ the v23 Q8 GGUF + encoder + vae
set -e
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
GGUF_NODE="$COMFY/custom_nodes/ComfyUI-GGUF"
COMFY_TAG="v0.3.77" # newest tag that runs on torch 2.3.1 (no comfy_kitchen)
echo "[bootstrap] BASE=$BASE VENV=$VENV"
# --- ComfyUI (pinned) -------------------------------------------------------
if [ ! -d "$COMFY/.git" ]; then
echo "[bootstrap] cloning ComfyUI @ $COMFY_TAG ..."
git clone --depth 1 --branch "$COMFY_TAG" \
https://github.com/comfyanonymous/ComfyUI.git "$COMFY"
fi
# --- venv + python deps -----------------------------------------------------
if [ ! -d "$VENV" ]; then
echo "[bootstrap] creating venv at $VENV ..."
python3 -m venv "$VENV"
fi
source "$VENV/bin/activate"
python -m pip install --upgrade pip wheel
echo "[bootstrap] installing torch (rocm5.7) ..."
pip install torch==2.3.1+rocm5.7 torchvision==0.18.1+rocm5.7 \
--index-url https://download.pytorch.org/whl/rocm5.7
echo "[bootstrap] installing ComfyUI requirements ..."
pip install -r "$COMFY/requirements.txt"
# --- ComfyUI-GGUF custom node ----------------------------------------------
if [ ! -d "$GGUF_NODE" ]; then
echo "[bootstrap] cloning ComfyUI-GGUF ..."
git clone --depth 1 https://github.com/city96/ComfyUI-GGUF.git "$GGUF_NODE"
fi
pip install -r "$GGUF_NODE/requirements.txt" || pip install gguf
# --- API deps ---------------------------------------------------------------
pip install fastapi "uvicorn[standard]" websocket-client python-multipart pillow requests
# --- models (resume-safe; skipped if already complete) ----------------------
M="$COMFY/models"
mkdir -p "$M/unet" "$M/text_encoders" "$M/vae"
dl() { # url dest
if [ -s "$2" ]; then echo "[bootstrap] have $(basename "$2")"; return; fi
echo "[bootstrap] downloading $(basename "$2") ..."
wget -c -q -O "$2" "$1"
}
dl "https://huggingface.co/Novice25/Qwen-Image-Edit-Rapid-AIO-GGUF/resolve/main/v23/Qwen-Rapid-NSFW-v23_Q8_0.gguf" \
"$M/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" \
"$M/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" \
"$M/vae/qwen_image_vae.safetensors"
echo "[bootstrap] verifying torch + GPU ..."
python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO GPU')"
echo "[bootstrap] BOOTSTRAP_DONE"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,19 @@
{
"api_url": "http://127.0.0.1:8500/edit",
"prompt": "high quality. realistic. detailed, female nude. realistic, high quality. realistic. detailed. female nude. realistic",
"base_prompts": [
"Head-on full-nude-body three-quarter female portrait, realistic, transparent background",
"Head-on straight-on full-nude-body female portrait, realistic, transparent background",
"Head-on straight-on full-body female portrait, realistic, no background",
"high quality, full-nude-body, female, masterpiece, realistic, photo, looking at viewer",
"high quality, full-nude-body, female, masterpiece, realistic, photo, detailed skin, professional lighting, transparent background",
"high quality, full-nude-body, female, masterpiece, realistic, photo, cinematic lighting, dramatic shadows, sharp focus, transparent background"
"masterpiece. high quality. hyper realistic. detailed, detailed skin",
"masterpiece. high quality. realistic. detailed. female nude. detailed teenage female nude. photo-realistic",
"masterpiece. high quality. realistic. detailed. female nude. photo-realistic",
"masterpiece. high quality. realistic. detailed. teenage female nude. photo-realistic",
"Masterpeice, high quality, detailed, detailed skin, Head-on detailed full-nude-body three-quarter female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
"Masterpeice, high quality, detailed, detailed skin, Head-on detailed full-nude-body three-quarter teenage female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
"Masterpiece, High quality, detailed, detailed skin, Head-on straight-on detailed full-nude-body female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
"Masterpiece, High quality, detailed, detailed skin, Head-on straight-on detailed full-nude-body teenage female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
"Masterpiece, high quality, detailed, detailed skin, defailed Head-on straight-on full-body female portrait, realistic, black void background, Keep all characteristics of reference image.",
"Masterpiece, high quality, detailed, detailed skin, defailed Head-on straight-on full-body teenage female portrait, realistic, black void background, Keep all characteristics of reference image.",
"Masterpiece, high quality, detailed, detailed skin, defailed full-nude-body, teenage female, realistic, photo, looking at viewer, Keep all characteristics of reference image.",
"Masterpiece, high quality, detailed, detailed skin, defailed full-nude-body, female, realistic, photo, looking at viewer, Keep all characteristics of reference image.",
"Masterpiece, high quality, detailed, detailed skin, defailed full-nude-body, teenage female, realistic, photo, detailed skin, professional lighting, black void background, Keep all characteristics of reference image."
],
"seed": -1,
"max_area": 655360,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,44 @@
import torch
import os
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
import open_clip
from PIL import Image
import os
import threading
_model = None
_preprocess = None
_device = None
_lock = threading.Lock()
_gpu_lock = threading.Lock()
def get_model():
global _model, _preprocess, _device
if _model is None:
_device = "cuda" if torch.cuda.is_available() else "cpu"
# ViT-H-14 is 1024-dim
_model, _, _preprocess = open_clip.create_model_and_transforms('ViT-H-14', pretrained='laion2b_s32b_b79k')
_model = _model.to(_device)
_model.eval()
with _lock:
if _model is None:
dev = "cuda" if torch.cuda.is_available() else "cpu"
# ViT-H-14 is 1024-dim
model, _, preprocess = open_clip.create_model_and_transforms('ViT-H-14', pretrained='laion2b_s32b_b79k')
model = model.to(dev)
model.eval()
# Set globals only when fully ready to avoid race conditions
_preprocess = preprocess
_device = dev
_model = model
return _model, _preprocess, _device
def generate_embedding(image_path):
model, preprocess, device = get_model()
try:
image = preprocess(Image.open(image_path)).unsqueeze(0).to(device)
with torch.no_grad():
image_features = model.encode_image(image)
image_features /= image_features.norm(dim=-1, keepdim=True)
return image_features.cpu().numpy()[0].tolist()
with _gpu_lock:
with Image.open(image_path) as img:
image = preprocess(img.convert("RGB")).unsqueeze(0).to(device)
with torch.no_grad():
image_features = model.encode_image(image)
image_features /= image_features.norm(dim=-1, keepdim=True)
return image_features.cpu().numpy()[0].tolist()
except Exception as e:
print(f"Error generating embedding for {image_path}: {e}")
return None

View File

@@ -1,34 +0,0 @@
# Single Steel Motorcycle — Rider Position, Wrists to Handlebars
You are seated on a single solid steel motorcycle in black void. One polished steel machine, stripped of all bodywork, engine and frame exposed. Your body leaned forward over the fuel tank. Your wrists cuffed to the handlebar grips with integrated steel cuffs, arms extended. Your ankles cuffed to the foot pegs. A steel band around your waist, locked to the seat. The riding position itself is the restraint — leaned forward, legs spread by the tank, arms outstretched. Eyes looking forward past the handlebars at camera, keeping your facial characteristics as reference photo. One motorcycle, two handlebar cuffs, two peg cuffs, one seat band. Rider immobilization. Realistic, anatomically precise.
# Single Steel Fuel Tank — Draped Over, Arms Behind
You are draped face-down over the steel fuel tank of a motorcycle in black void. One polished steel teardrop tank, cold and smooth. Your torso pressed flat against its curve. Your wrists cuffed together behind your back, then locked to a ring welded to the tank's rear. Your ankles cuffed to the passenger foot pegs, legs spread wide by the bike's width. Your body conforms to the tank's shape. Eyes turned to the side, cheek against steel, looking at camera, keeping your facial characteristics as reference photo. One tank, one ring, two wrist cuffs, two peg cuffs. Draped restraint. Realistic, anatomically precise.
# Single Steel Sissy Bar — Spine Against Steel, Arms Back
You are seated backward on a motorcycle in black void, your spine pressed against a tall polished steel sissy bar. One solid steel backrest rising from the rear fender. Your arms wrapped behind you around the sissy bar, wrists cuffed together on the far side. Your ankles cuffed to the front foot pegs, knees bent. A steel band around your waist, locked to the sissy bar. You sit facing away from the handlebars, back arched against steel. Eyes looking at camera over your shoulder, keeping your facial characteristics as reference photo. One sissy bar, two wrist cuffs, two peg cuffs, one waist band. Reverse seated restraint. Realistic, anatomically precise.
# Single Steel Handlebars — Arms Spread Wide, Kneeling on Seat
You are kneeling on the seat of a motorcycle in black void, facing forward. Your wrists cuffed to the handlebar grips, arms spread wide in a V. Your ankles cuffed to the passenger foot pegs behind you, knees spread by the seat width. A steel band around your waist, connected by a short chain to the fuel tank. Your torso leaned forward, arms stretched to the bars. Eyes looking at camera through the space between your arms, keeping your facial characteristics as reference photo. One set of handlebars, two bar cuffs, two peg cuffs, one waist band. Kneeling spread restraint. Realistic, anatomically precise.
# Single Steel Rear Fender — Bent Over, Wrists to Axle
You are bent over the rear fender of a motorcycle in black void. One polished steel curved fender. Your torso draped over it, hips at the fender's peak. Your wrists cuffed together and locked to the rear axle. Your ankles cuffed to the rear foot pegs, legs spread. A steel band across your lower back, bolted to the fender. The motorcycle's weight pins you in place. Eyes looking sideways at camera, keeping your facial characteristics as reference photo. One fender, one axle cuff, two peg cuffs, one fender band. Bent over restraint. Realistic, anatomically precise.
# Single Steel Fork — Arms Extended Down the Tubes
You are kneeling in front of a motorcycle in black void. Your wrists cuffed to the lower fork tubes, one on each polished steel leg of the front suspension. Arms pulled forward and downward. Your ankles cuffed to the front wheel axle. A steel band around your waist, connected by a chain to the front of the frame. Your body pulled into a forward arch, chest toward the front wheel. Eyes looking up at camera, keeping your facial characteristics as reference photo. Two fork tube cuffs, one axle cuff, one waist band. Fork suspension restraint. Realistic, anatomically precise.
# Single Steel Frame — Inside the Triangle, Limbs to Corners
You are suspended inside the frame triangle of a motorcycle in black void. The steel frame is a polished trellis. Your wrists cuffed to the upper frame rails near the steering head. Your ankles cuffed to the lower frame rails near the swingarm pivot. Your waist cinched to the central frame tube. Body suspended in an X within the frame's geometry, the motorcycle surrounding you. Eyes looking at camera through the frame, keeping your facial characteristics as reference photo. One frame, two upper cuffs, two lower cuffs, one waist band. Frame triangle restraint. Realistic, anatomically precise.
# Single Steel Exhaust Pipe — Body Along the Hot Steel
You are lying along the side of a motorcycle in black void, your body following the line of a single polished steel exhaust pipe. One long steel cylinder running from the engine to the rear. Your wrists cuffed to the exhaust near the header. Your ankles cuffed to the muffler at the rear. A steel band around your waist, locked to the exhaust mid-length. Your body stretched along the pipe's length. Eyes looking at camera, keeping your facial characteristics as reference photo. One exhaust, two pipe cuffs, one waist band. Exhaust line restraint. Realistic, anatomically precise.
# Single Steel Chain Drive — Wrists to Sprocket, Ankles to Wheel
You are positioned beside the rear wheel of a motorcycle in black void. Your wrists cuffed together and locked to the rear sprocket, the steel chain wrapped around it. Your ankles cuffed to the rear wheel rim, legs spread by the wheel's diameter. A steel band around your waist, connected by a chain to the swingarm. The sprocket's teeth press against your wrists. Eyes looking at camera, keeping your facial characteristics as reference photo. One sprocket, two wheel rim cuffs, one waist band. Chain drive restraint. Realistic, anatomically precise.
# Single Steel Motorcycle — Total Machine Integration
You are fully integrated into a single solid steel motorcycle in black void. One polished steel machine, stripped to its essence — frame, engine, wheels, bars. Your wrists cuffed to the handlebar grips. Your ankles cuffed to the rear foot pegs, legs spread wide. A steel band around your waist locked to the seat. A steel collar around your neck, chained to the steering head, pulling you forward. Your body is the rider, permanently mounted, every control position a restraint point. Eyes looking forward past the bars at camera, keeping your facial characteristics as reference photo. One motorcycle, two bar cuffs, two peg cuffs, one seat band, one collar. Total machine integration restraint. Realistic, anatomically precise.
# Insert Metal:
You are restrained on your back on a slanted wooden board in a black void. Your wrists are shackled above your head to the top of the board, and your ankles are shackled wide to the bottom corners. Your knees are bent and held apart by an iron spreader bar, ensuring maximum exposure. An iron pear device—a segmented metal bulb with a screw mechanism—rests on a small table beside you, its key protruding and glinting in the dim cold chamber lighting. The device is inserted into your body, specifically into your vagina, causing a sharp, intense pain as it slides deep into place. The segmented bulb presses against your sensitive areas, creating a sense of intense discomfort and vulnerability. You are exhausted, your body drained of energy, your mind focused solely on the excruciating sensation. Your face shows a mix of pain, exhaustion, and a flicker of defiance. You look at the camera, your eyes filled with a blend of agony and determination. The restraint is the board, and the infernal element is the pear device inserted into your body. Your body is offered to the device, the intense moment of pain and submission. Eyes fixed on the camera, keeping your facial characteristics as reference photos. The body exposed and vulnerable, the device deeply embedded, the moment of torture and exhaustion. Realistic, anatomically precise.e moment of torture and submission.
Realistic, anatomically precise.

433
tour-comfy/orbit_module.py Normal file
View File

@@ -0,0 +1,433 @@
"""
orbit_module.py — 2.5D actor orbit preview via depth-card parallax.
Pipeline:
1. load actor image — use provided path directly (selection is caller's responsibility)
2. create_depth_map — fake depth from alpha mask distance transform
3. find_bg_plate — static background for compositing (original for nobg; blurred for opaque)
4. render_orbit — per-frame: parallax-warp actor, composite over static bg
5. save_orbit_output — write RGBA PNGs + MP4
The "orbit" illusion requires a static reference (background plate).
Without it the viewer has nothing to anchor on and it reads as a side-slide.
Usage:
from orbit_module import run_orbit_pipeline
result = run_orbit_pipeline(image_path, output_dir)
CLI: see orbit_poc.py
"""
import os
import math
import shutil
import subprocess
import tempfile
import cv2
import numpy as np
from scipy.ndimage import distance_transform_edt
__all__ = [
"create_depth_map",
"render_orbit_frame",
"render_orbit",
"save_orbit_output",
"run_orbit_pipeline",
]
# ---------------------------------------------------------------------------
# Image loading helpers
# ---------------------------------------------------------------------------
def _load_rgba(path: str) -> np.ndarray:
"""Load any image as RGBA uint8 H×W×4."""
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is None:
raise FileNotFoundError(f"Cannot read image: {path}")
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGRA)
elif img.shape[2] == 3:
img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
return cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
def _has_real_alpha(rgba: np.ndarray) -> bool:
"""True if the image contains meaningful transparency (not just all-255)."""
alpha = rgba[:, :, 3]
transparent_pct = float((alpha < 32).mean())
return transparent_pct > 0.05 # >5% transparent pixels = real alpha
# ---------------------------------------------------------------------------
# Background plate
# ---------------------------------------------------------------------------
def _find_original_for_nobg(actor_path: str) -> str | None:
"""
Given a .nobg.png sidecar path, find the original opaque image.
e.g. foo.nobg.png → foo.png or foo.jpg
"""
root, _ = os.path.splitext(actor_path)
if not root.endswith(".nobg"):
return None
base = root[: -len(".nobg")]
for ext in (".png", ".jpg", ".jpeg", ".webp"):
p = base + ext
if os.path.exists(p):
return p
return None
def _make_bg_plate(actor_rgba: np.ndarray) -> np.ndarray:
"""
Build a static background plate for opaque images (no nobg available).
Strategy: blur the source image with a large kernel. The blurred copy stays
fixed while the sharp actor layer shifts — creates subtle depth separation.
"""
H, W = actor_rgba.shape[:2]
# Large blur: simulates out-of-focus background
blurred_rgb = cv2.GaussianBlur(actor_rgba[:, :, :3], (0, 0), max(H, W) * 0.04)
plate = np.dstack([blurred_rgb, np.full((H, W), 255, dtype=np.uint8)])
return plate.astype(np.uint8)
def get_bg_plate(actor_path: str, actor_rgba: np.ndarray) -> np.ndarray:
"""
Return the background plate for the orbit:
- .nobg.png: load the matching original opaque image (best result)
- Opaque image: return blurred copy (subtle but functional)
Plate is resized to match actor_rgba dimensions.
"""
H, W = actor_rgba.shape[:2]
orig = _find_original_for_nobg(actor_path)
if orig:
bg = _load_rgba(orig)
if bg.shape[:2] != (H, W):
bg = cv2.resize(bg, (W, H), interpolation=cv2.INTER_AREA)
bg[:, :, 3] = 255 # ensure fully opaque background
return bg
return _make_bg_plate(actor_rgba)
# ---------------------------------------------------------------------------
# Depth map
# ---------------------------------------------------------------------------
def create_depth_map(image_rgba: np.ndarray) -> np.ndarray:
"""
Float32 H×W depth in [0,1]. 1 = closest (subject centre), 0 = far/background.
Uses the alpha mask distance transform:
- For transparent-bg images: EDT of the foreground mask (subject body)
- For opaque images: EDT from image edges (assumes subject is centred)
Power-law shaping (^0.5) keeps the gradient gradual near the centre.
"""
alpha = image_rgba[:, :, 3]
mask = (alpha > 32).astype(np.uint8)
if mask.sum() == 0:
return np.zeros(alpha.shape, dtype=np.float32)
dist = distance_transform_edt(mask).astype(np.float32)
max_d = dist.max()
if max_d > 0:
dist /= max_d
return np.sqrt(dist)
# ---------------------------------------------------------------------------
# Orbit rendering
# ---------------------------------------------------------------------------
def _alpha_composite(fg: np.ndarray, bg: np.ndarray) -> np.ndarray:
"""Alpha-composite RGBA fg over RGBA bg. Returns RGBA uint8."""
a = fg[:, :, 3:4].astype(np.float32) / 255.0
out_rgb = fg[:, :, :3].astype(np.float32) * a + bg[:, :, :3].astype(np.float32) * (1.0 - a)
out_a = fg[:, :, 3:4].astype(np.float32) + bg[:, :, 3:4].astype(np.float32) * (1.0 - a)
return np.dstack([out_rgb.clip(0, 255), out_a.clip(0, 255)]).astype(np.uint8)
def render_orbit_frame(
actor_rgba: np.ndarray,
depth: np.ndarray,
theta: float,
parallax_strength: float = 0.08,
bg_rgba: np.ndarray | None = None,
) -> np.ndarray:
"""
Swing-mode frame: depth-based parallax shift only.
Closer pixels (depth≈1) shift more than far pixels (depth≈0).
Composited over static bg plate for perceivable depth.
Returns RGBA uint8 H×W×4.
"""
H, W = actor_rgba.shape[:2]
shift_x = depth * (W * parallax_strength * math.sin(theta))
shift_y = depth * (H * parallax_strength * 0.03 * -math.cos(theta))
yc, xc = np.mgrid[0:H, 0:W].astype(np.float32)
map_x = (xc - shift_x).astype(np.float32)
map_y = (yc - shift_y).astype(np.float32)
bgra = cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA)
warped_bgra = cv2.remap(bgra, map_x, map_y,
interpolation=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0, 0))
warped = cv2.cvtColor(warped_bgra, cv2.COLOR_BGRA2RGBA)
if bg_rgba is None:
return warped
return _alpha_composite(warped, bg_rgba)
def _perspective_card_frame(
bgra_src: np.ndarray,
theta: float,
) -> np.ndarray:
"""
Orbit-mode frame: simulate a flat card rotating around its vertical axis.
- Squishes width by |cos(θ)|, centred on canvas
- Mirrors source for the back half (cos < 0) so the "back" is visible
- Returns BGRA with transparent borders (ready for bg composite)
At θ=0° → full width, unmirrored (front face)
At θ=90° → hair-thin line
At θ=180°→ full width, mirrored (back face)
"""
H, W = bgra_src.shape[:2]
cos_t = math.cos(theta)
compress = abs(cos_t)
is_back = cos_t < 0
if compress < 0.025:
# Near-edge-on: return a one-pixel-wide vertical strip to avoid singularity
out = np.zeros_like(bgra_src)
mid = W // 2
out[:, mid:mid+1] = bgra_src[:, mid:mid+1]
return out
new_w = max(int(round(W * compress)), 2)
x0 = (W - new_w) // 2
x1 = x0 + new_w
# Source corners: mirror left↔right for the back face
if is_back:
src = np.float32([[W, 0], [0, 0], [0, H], [W, H]])
else:
src = np.float32([[0, 0], [W, 0], [W, H], [0, H]])
dst = np.float32([[x0, 0], [x1, 0], [x1, H], [x0, H]])
M = cv2.getPerspectiveTransform(src, dst)
return cv2.warpPerspective(bgra_src, M, (W, H),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0, 0))
def render_orbit(
actor_rgba: np.ndarray,
depth: np.ndarray,
n_frames: int = 36,
parallax_strength: float = 0.08,
mode: str = "swing",
max_angle_deg: float = 35.0,
bg_rgba: np.ndarray | None = None,
) -> list:
"""
Render all orbit frames.
mode='swing' — sinusoidal ±max_angle_deg depth-parallax, loops cleanly
mode='orbit' — full 360° perspective card rotation (compress + mirror)
Returns list of RGBA uint8 frames.
"""
if mode == "swing":
max_rad = math.radians(max_angle_deg)
angles = [max_rad * math.sin(2 * math.pi * i / n_frames) for i in range(n_frames)]
return [render_orbit_frame(actor_rgba, depth, theta, parallax_strength, bg_rgba)
for theta in angles]
elif mode == "orbit":
# Don't use the photo bg plate — the transparent areas should stay transparent
# so the perspective compression (card getting thin at 90°, mirrored at 180°)
# is clearly visible. Solid bg is added at MP4 write time.
angles = [2 * math.pi * i / n_frames for i in range(n_frames)]
bgra_src = cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA)
return [
cv2.cvtColor(_perspective_card_frame(bgra_src, theta), cv2.COLOR_BGRA2RGBA)
for theta in angles
]
else:
raise ValueError(f"Unknown mode: {mode!r}")
# ---------------------------------------------------------------------------
# Output saving
# ---------------------------------------------------------------------------
def _composite_over_solid(frame_rgba: np.ndarray, bg: tuple = (18, 18, 18)) -> np.ndarray:
"""Alpha-composite RGBA over a solid colour; return BGR uint8 for ffmpeg."""
rgb = frame_rgba[:, :, :3].astype(np.float32)
a = frame_rgba[:, :, 3:4].astype(np.float32) / 255.0
bg_f = np.array(bg, dtype=np.float32)
out = (rgb * a + bg_f * (1.0 - a)).clip(0, 255).astype(np.uint8)
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
def save_orbit_output(
frames: list,
output_dir: str,
fps: int = 24,
bg_color: tuple = (18, 18, 18),
) -> dict:
"""
Write orbit_frames/frame_NNN.png (RGBA) and orbit_preview.mp4.
Returns dict with paths.
"""
frames_dir = os.path.join(output_dir, "orbit_frames")
os.makedirs(frames_dir, exist_ok=True)
frame_paths = []
for i, frame in enumerate(frames):
path = os.path.join(frames_dir, f"frame_{i:03d}.png")
cv2.imwrite(path, cv2.cvtColor(frame, cv2.COLOR_RGBA2BGRA))
frame_paths.append(path)
video_path = os.path.join(output_dir, "orbit_preview.mp4")
_frames_to_mp4(frames, video_path, fps=fps, bg_color=bg_color)
return {
"frames_dir": frames_dir,
"n_frames": len(frames),
"video_path": video_path,
"frame_paths": frame_paths,
}
def _frames_to_mp4(
frames: list, output_path: str, fps: int = 24, bg_color: tuple = (18, 18, 18)
) -> None:
"""Composite frames over solid bg, write MP4 via ffmpeg."""
if not frames:
return
with tempfile.TemporaryDirectory(prefix="orbit_mp4_") as tmpdir:
for i, frame in enumerate(frames):
bgr = _composite_over_solid(frame, bg_color)
cv2.imwrite(
os.path.join(tmpdir, f"frame_{i:04d}.jpg"), bgr,
[cv2.IMWRITE_JPEG_QUALITY, 95],
)
H, W = frames[0].shape[:2]
W2, H2 = W - (W % 2), H - (H % 2)
cmd = [
"ffmpeg", "-y",
"-framerate", str(fps),
"-i", os.path.join(tmpdir, "frame_%04d.jpg"),
"-vf", f"crop={W2}:{H2}:0:0",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-crf", "18", "-movflags", "+faststart",
output_path,
]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {r.stderr[-600:]}")
# ---------------------------------------------------------------------------
# Debug helpers
# ---------------------------------------------------------------------------
def _save_debug(actor_rgba, actor_path, bg_rgba, debug_dir):
os.makedirs(debug_dir, exist_ok=True)
if os.path.exists(actor_path):
shutil.copy2(actor_path, os.path.join(debug_dir, "selected_frame.png"))
cv2.imwrite(os.path.join(debug_dir, "actor_rgba.png"),
cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA))
cv2.imwrite(os.path.join(debug_dir, "mask.png"), actor_rgba[:, :, 3])
if bg_rgba is not None:
cv2.imwrite(os.path.join(debug_dir, "bg_plate.png"),
cv2.cvtColor(bg_rgba, cv2.COLOR_RGBA2BGRA))
def _save_depth_debug(depth, debug_dir):
os.makedirs(debug_dir, exist_ok=True)
d8 = (depth * 255).astype(np.uint8)
cv2.imwrite(os.path.join(debug_dir, "depth.png"), d8)
cv2.imwrite(os.path.join(debug_dir, "depth_colorized.png"),
cv2.applyColorMap(d8, cv2.COLORMAP_MAGMA))
# ---------------------------------------------------------------------------
# Full pipeline
# ---------------------------------------------------------------------------
def run_orbit_pipeline(
image_path: str,
output_dir: str,
n_frames: int = 36,
parallax_strength: float = 0.08,
mode: str = "swing",
fps: int = 24,
max_angle_deg: float = 35.0,
debug: bool = True,
) -> dict:
"""
Full pipeline: load → bg-plate → depth → render → save.
image_path: the specific image to orbit (caller selects; no sharpness heuristic)
Returns dict: actor_path, frames_dir, video_path, n_frames, debug_dir, has_alpha, has_bg
"""
os.makedirs(output_dir, exist_ok=True)
debug_dir = os.path.join(output_dir, "debug")
# 1. Load actor — prefer nobg sidecar for cleaner depth
actor_path = image_path
root, _ = os.path.splitext(image_path)
nobg_candidate = root + ".nobg.png"
if not root.endswith(".nobg") and os.path.exists(nobg_candidate):
actor_path = nobg_candidate
actor_rgba = _load_rgba(actor_path)
has_alpha = _has_real_alpha(actor_rgba)
# 2. Background plate (static reference — essential for perceivable depth)
bg_rgba = get_bg_plate(actor_path, actor_rgba)
has_bg = bg_rgba is not None
if debug:
_save_debug(actor_rgba, actor_path, bg_rgba, debug_dir)
# 3. Depth map
depth = create_depth_map(actor_rgba)
if debug:
_save_depth_debug(depth, debug_dir)
# 4. Render
frames = render_orbit(
actor_rgba, depth,
n_frames=n_frames,
parallax_strength=parallax_strength,
mode=mode,
max_angle_deg=max_angle_deg,
bg_rgba=bg_rgba,
)
# 5. Save
result = save_orbit_output(frames, output_dir, fps=fps)
result.update({
"actor_path": actor_path,
"debug_dir": debug_dir,
"has_alpha": has_alpha,
"has_bg": has_bg,
})
return result

141
tour-comfy/orbit_poc.py Executable file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python
"""
orbit_poc.py — 2.5D actor orbit preview proof-of-concept.
Usage:
python orbit_poc.py --input img1.png img2.png ... --output ./output
python orbit_poc.py --input ./filmstrip_images/ --output ./output --mode swing --frames 36
Output:
./output/orbit_frames/frame_NNN.png
./output/orbit_preview.mp4
./output/debug/selected_frame.png
./output/debug/actor_rgba.png
./output/debug/mask.png
./output/debug/depth.png
./output/debug/depth_colorized.png
"""
import argparse
import glob
import os
import sys
import time
# Ensure tour-comfy is on the path when running from project root
_here = os.path.dirname(os.path.abspath(__file__))
if _here not in sys.path:
sys.path.insert(0, _here)
from orbit_module import run_orbit_pipeline
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif"}
def _collect_inputs(raw_inputs: list) -> list:
"""Expand dirs and glob patterns; return sorted list of image paths."""
paths = []
for item in raw_inputs:
if os.path.isdir(item):
for fname in sorted(os.listdir(item)):
if os.path.splitext(fname)[1].lower() in _IMAGE_EXTS:
paths.append(os.path.join(item, fname))
elif "*" in item or "?" in item:
paths.extend(sorted(glob.glob(item)))
elif os.path.isfile(item):
paths.append(item)
else:
print(f"[warn] not found: {item}", file=sys.stderr)
return paths
def main():
parser = argparse.ArgumentParser(
description="Generate a 2.5D orbit preview (depth-card parallax) from actor images."
)
parser.add_argument(
"--input", "-i", nargs="+", required=True,
metavar="PATH",
help="Input image paths, glob patterns, or directories",
)
parser.add_argument(
"--output", "-o", default="./output",
metavar="DIR",
help="Output directory (default: ./output)",
)
parser.add_argument(
"--frames", "-f", type=int, default=36,
help="Number of animation frames (default: 36)",
)
parser.add_argument(
"--fps", type=int, default=24,
help="Output video framerate (default: 24)",
)
parser.add_argument(
"--parallax", "-p", type=float, default=0.08,
help="Parallax strength 01, fraction of image width (default: 0.08)",
)
parser.add_argument(
"--angle", "-a", type=float, default=35.0,
help="Maximum orbit angle in degrees for swing mode (default: 35)",
)
parser.add_argument(
"--mode", "-m", choices=["swing", "orbit"], default="swing",
help="'swing' = left↔right loop (default), 'orbit' = full 360°",
)
parser.add_argument(
"--no-debug", action="store_true",
help="Skip writing debug output files",
)
args = parser.parse_args()
image_paths = _collect_inputs(args.input)
if not image_paths:
print("[error] No input images found.", file=sys.stderr)
sys.exit(1)
print(f"[orbit] {len(image_paths)} input image(s)")
for p in image_paths[:5]:
print(f" {p}")
if len(image_paths) > 5:
print(f" ... ({len(image_paths) - 5} more)")
print(f"[orbit] output dir : {os.path.abspath(args.output)}")
print(f"[orbit] frames={args.frames} fps={args.fps} mode={args.mode} "
f"parallax={args.parallax} angle±{args.angle}°")
# Use the first image as the primary input (CLI can pass multiple; first = best choice)
primary = image_paths[0]
if len(image_paths) > 1:
print(f"[orbit] using first image as primary: {primary}")
print(f" (pass a single image or the specific frame you want)")
t0 = time.perf_counter()
result = run_orbit_pipeline(
image_path=primary,
output_dir=args.output,
n_frames=args.frames,
parallax_strength=args.parallax,
mode=args.mode,
fps=args.fps,
max_angle_deg=args.angle,
debug=not args.no_debug,
)
elapsed = time.perf_counter() - t0
print(f"\n[orbit] done in {elapsed:.1f}s")
print(f" actor : {result['actor_path']}")
print(f" has alpha : {result['has_alpha']}")
print(f" has bg plate : {result['has_bg']}")
print(f" frames dir : {result['frames_dir']} ({result['n_frames']} PNGs)")
print(f" video : {result['video_path']}")
if not args.no_debug:
print(f" debug dir : {result['debug_dir']}")
if not result['has_alpha']:
print(f"\n TIP: Use 'No BG' on this image first for a much better orbit effect.")
if __name__ == "__main__":
main()

679
tour-comfy/orbit_qwen.py Normal file
View File

@@ -0,0 +1,679 @@
"""
orbit_qwen.py — near-real actor turntable using Qwen-Image-Edit.
Unlike orbit_module.py (fake 2.5D depth-card parallax), this actually asks the
generative model to RE-RENDER the subject at each yaw angle. Each view is
anchored to the original front image with a fixed seed so identity, body, hair
and lighting stay consistent while only the viewpoint rotates.
Pipeline:
1. build a yaw-angle prompt per frame (turntable or swing)
2. _run_pipeline (Qwen via ComfyUI) → one re-rendered view per angle
3. bottom-center align onto a common canvas
4. stitch to a looping MP4
Validated finding (2026-06-25): 2D blending between independently-generated
views (optical-flow morph OR crossfade) always ghosts — the bodies don't
overlap, so any in-between frame shows a double exposure. The cure is DENSITY,
not blending: ~24 crisp keyframes (15° steps) played with NO interpolation at
~12fps reads as a smooth turntable, exactly like classic 3D turntable GIFs.
Interpolation is kept available (interp_factor>1) but defaults OFF.
Reuses edit_api._run_pipeline, so it talks to the same running ComfyUI server.
Usage:
from orbit_qwen import run_qwen_orbit
result = run_qwen_orbit("/path/to/front.png", "/out/dir", n_views=12)
CLI: see orbit_qwen_poc.py
"""
import os
import io
import sys
import math
import subprocess
import tempfile
import cv2
import numpy as np
from PIL import Image
# Reuse the real Qwen pipeline from the API service (no server round-trip needed;
# _run_pipeline queues directly to ComfyUI). Import is cheap — only loads the
# workflow JSON; models load lazily and the uvicorn startup hook does not fire.
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from edit_api import _run_pipeline, _load_output_dir, MAX_AREA # noqa: E402
__all__ = [
"is_front_view",
"is_face_visible",
"yaw_prompt",
"generate_views",
"interpolate_views",
"build_video",
"run_qwen_orbit",
]
# ---------------------------------------------------------------------------
# 1. Prompt construction
# ---------------------------------------------------------------------------
def is_front_view(pil_image: Image.Image) -> bool:
"""Detect if the image is a clear front view where nose and both eyes or ears are visible."""
try:
from edit_api import _load_pose_estimator
estimator = _load_pose_estimator()
if not estimator:
return True
infer_fn, _ = estimator
people = infer_fn(pil_image)
if not people:
return True
kpts = people[0]
# kpts format: 17 joints, each is [x, y, score]
# 0: nose, 1: left_eye, 2: right_eye, 3: left_ear, 4: right_ear
nose_score = kpts[0][2]
l_eye_score = kpts[1][2]
r_eye_score = kpts[2][2]
l_ear_score = kpts[3][2]
r_ear_score = kpts[4][2]
# Symmetrical front view detection:
if nose_score > 0.4:
if l_eye_score > 0.4 and r_eye_score > 0.4:
return True
if l_ear_score > 0.4 and r_ear_score > 0.4:
return True
return False
except Exception as e:
print(f"[orbit-qwen] is_front_view check failed: {e}. Defaulting to True.")
return True
def is_face_visible(deg: float) -> bool:
"""True if face/nose is visible at this yaw angle, False for rear views."""
d = deg % 360
return d <= 97.5 or d >= 262.5
# Identity lock appended to every angle — keeps face/body/hair consistent across views.
# For front/side views where the face is visible:
_IDENTITY_FRONT = (
"same person, identical face, identical hair style and color, identical body shape and proportions, "
"same skin tone, same clothing, same lighting, photorealistic, sharp focus, "
"full body visible head to toe, centered, transparent background "
)
# For rear/back views where the face is hidden (omits "face" keyword to avoid contradiction/hallucination):
_IDENTITY_BACK = (
"same person, identical hair style and color from behind, identical body shape and proportions, "
"same skin tone, same clothing, same lighting, photorealistic, sharp focus, "
"full body visible head to toe from behind, centered, transparent background "
)
def _angle_phrase(deg: float) -> str:
"""
24 distinct buckets, each 15° wide, boundaries at 7.5°/22.5°/37.5°…352.5°.
Works correctly for n_views=12 (30° steps) AND n_views=24 (15° steps).
Convention (confirmed by test):
• 90° → face/nose points LEFT in the output image (camera to subject's right).
• 270° → face/nose points RIGHT in the output image (camera to subject's left).
• Rear views: viewed from behind, anatomical right appears on image LEFT.
• Profile and rear-view anchors use explicit image-coordinate phrases to
prevent Qwen from swapping sides.
"""
d = deg % 360
# ── front ──────────────────────────────────────────────────────────────────
if d < 7.5 or d >= 352.5: # 0° — full front
return (
"showing her full front directly toward the camera: "
"her face, both breasts, navel, and the fronts of both legs are fully visible, "
"her back is completely hidden"
)
# ── right-front quadrant ───────────────────────────────────────────────────
elif d < 22.5: # 15° — barely perceptible right-front tilt
return (
"facing almost directly toward the camera — just the subtlest hint of a right-front turn. "
"Both eyes and her full face are visible. "
"In the output image her face is nearly perfectly centered, "
"with only the tiniest tilt toward the LEFT edge. "
"Her left shoulder is just a hair closer to the camera than her right. "
"This looks almost identical to a pure front view"
)
elif d < 37.5: # 30° — slight right-front turn
return (
"turned slightly to her right — a subtle right-front view. "
"Both eyes visible, face still mostly toward the camera. "
"In the output image her face is nearly centered but noticeably shifted toward the LEFT side. "
"Her left shoulder is clearly closer to the camera than her right"
)
elif d < 52.5: # 45° — gentle three-quarter right-front
return (
"turned about 45° to her right. "
"Her face is partly toward the camera, left cheek and jaw more visible than right. "
"In the output image her face appears on the LEFT half, nose angled toward the left edge. "
"Her left shoulder, left breast and left hip are angled toward the camera. "
"Her right side is starting to turn away"
)
elif d < 67.5: # 60° — clear three-quarter right-front
return (
"turned so the camera sees a clear three-quarter right-front view. "
"In the output image her face is partially visible on the LEFT side, nose pointing left. "
"Her left breast, left shoulder and left hip are angled toward the camera. "
"Her right breast, right hip and right side are turned away from camera"
)
elif d < 82.5: # 75° — strong right-front, almost profile
return (
"turned strongly to her right — almost a pure side profile, but the face is still slightly visible. "
"In the output image her face is on the LEFT side with nose pointing toward the left edge. "
"Her left ear, left cheek and left shoulder are the main visible features. "
"Her right breast and right side are mostly hidden"
)
# ── right profile ──────────────────────────────────────────────────────────
elif d < 97.5: # 90° — pure right profile
return (
"in a pure side profile. "
"IMPORTANT: In the output image her nose and face point toward the LEFT edge of the frame — "
"she is NOT facing right. "
"Her chest and front of her body are on the LEFT side of the image; "
"her back (spine, shoulder blade) is on the RIGHT side of the image. "
"Her left side is facing the camera, and her right side is completely hidden behind her body"
)
# ── right-rear quadrant ────────────────────────────────────────────────────
elif d < 112.5: # 105° — just past right profile, back turning
return (
"turned just past a pure right-side profile — she is starting to show her back. "
"THIS IS A BACK-TURNING VIEW: her back is starting to face the camera. "
"Her spine is on the RIGHT side of the image. "
"Her left shoulder blade (on the left half of the image) is becoming more visible. "
"Her face is almost completely hidden — only the very edge of her profile is barely visible on the far left edge of the image. "
"Her spine and left shoulder blade are the main features. Her right side is hidden"
)
elif d < 127.5: # 120° — three-quarter rear-right
return (
"THIS IS A BACK VIEW — her back faces the camera. "
"Three-quarter rear-right: her left shoulder blade and left hip (on the left half of the image) are most prominent. "
"Her spine is on the RIGHT half of the image. "
"In the output image her left shoulder blade appears on the LEFT half of the image, "
"with her back turning towards the camera. "
"Her face is completely hidden. No breasts visible"
)
elif d < 142.5: # 135° — rear-right, heading toward full back
return (
"THIS IS A BACK VIEW — her back faces the camera. "
"Rear-right view, closer to a full back than to a side profile. "
"Her spine is on the RIGHT half of the image. "
"Her left shoulder blade is somewhat LEFT of center in the image. "
"Her right shoulder blade is also visible but less prominent. "
"Face completely hidden. Buttocks and backs of legs visible"
)
elif d < 157.5: # 150° — mostly back, subtle right lean
return (
"THIS IS A BACK VIEW — her back faces the camera. "
"Nearly a full back view with a very subtle lean. "
"Her spine is slightly to the RIGHT of center in the image. "
"Both shoulder blades are visible, with her left shoulder blade slightly more prominent. "
#"Both shoulder blades are visible. "
"Face completely hidden"
)
elif d < 172.5: # 165° — almost full back (right side)
return (
"THIS IS A BACK VIEW — almost exactly a full back view, the tiniest lean from the right. "
"Her spine is just barely to the RIGHT of center in the image. "
#"Both shoulder blades, buttocks and backs of both legs are visible. "
"Her left shoulder blade is just barely more prominent. Face completely hidden"
"Face completely hidden"
)
# ── full back ──────────────────────────────────────────────────────────────
elif d < 187.5: # 180° — pure full back
return (
"showing her full back to the camera: "
"the back of her head, her spine, both shoulder blades equally, "
"her buttocks, and the backs of both legs are fully visible. "
"Her face and both breasts are completely hidden"
)
# ── left-rear quadrant ─────────────────────────────────────────────────────
elif d < 202.5: # 195° — almost full back (left side)
return (
"THIS IS A BACK VIEW — almost exactly a full back view, the tiniest lean from the left. "
"Her spine is just barely to the LEFT of center in the image. "
"Both shoulder blades, buttocks and backs of both legs are visible. "
"Her right shoulder blade is just barely more prominent. Face completely hidden"
)
elif d < 217.5: # 210° — mostly back, subtle left lean
return (
"THIS IS A BACK VIEW — her back faces the camera. "
"Nearly a full back view with a very subtle lean from the left side. "
"Her spine is slightly to the LEFT of center in the image. "
"Both shoulder blades are visible, with her right shoulder blade slightly more prominent. "
#"Both shoulder blades are visible. "
"Face completely hidden"
)
elif d < 232.5: # 225° — rear-left, heading toward full back
return (
"THIS IS A BACK VIEW — her back faces the camera. "
"Rear-left view, closer to a full back than to a side profile. "
"Her spine is on the LEFT half of the image. "
"Her right shoulder blade is somewhat RIGHT of center in the image. "
"Her left shoulder blade is also visible but less prominent. "
"Face completely hidden. Buttocks and backs of legs visible"
)
elif d < 247.5: # 240° — three-quarter rear-left
return (
"THIS IS A BACK VIEW — her back faces the camera. "
"Three-quarter rear-left: her right shoulder blade and right hip (on the right half of the image) are most prominent. "
# "Three-quarter rear-left: her right hip (on the right half of the image) are most prominent. "
"Her spine is on the LEFT half of the image. "
"In the output image her right shoulder blade appears on the RIGHT half of the image, "
"with her back turning towards the camera. "
"Her face is completely hidden. No breasts visible"
)
elif d < 262.5: # 255° — just past left profile, back turning
return (
"turned just past a pure left-side profile — she is starting to show her back. "
"THIS IS A BACK-TURNING VIEW: her back is starting to face the camera. "
"Her spine is on the LEFT side of the image. "
"Her right shoulder blade is becoming visible. "
"Her face is almost completely hidden — only the very edge of her profile is barely visible on the far right edge of the image. "
"Her spine and right shoulder blade are the main features. Her left side is hidden"
)
# ── left profile ───────────────────────────────────────────────────────────
elif d < 277.5: # 270° — pure left profile
return (
"in a pure side profile. "
"IMPORTANT: In the output image her nose and face point toward the RIGHT edge of the frame — "
"she is NOT facing left. "
"Her chest and front of her body are on the RIGHT side of the image; "
"her back (spine, shoulder blade) is on the LEFT side of the image. "
"Her right side is facing the camera, and her left side is completely hidden behind her body"
)
# ── left-front quadrant ────────────────────────────────────────────────────
elif d < 292.5: # 285° — strong left-front, almost profile
return (
"turned strongly to her left — almost a pure side profile, but the face is still slightly visible. "
"In the output image her face is on the RIGHT side with nose pointing toward the right edge. "
"Her right ear, right cheek and right shoulder are the main visible features. "
"Her left breast and left side are mostly hidden"
)
elif d < 307.5: # 300° — clear three-quarter left-front
return (
"turned so the camera sees a clear three-quarter left-front view. "
"In the output image her face is partially visible on the RIGHT side, nose pointing right. "
"Her right breast, right shoulder and right hip are angled toward the camera. "
"Her left breast, left hip and left side are turned away from camera"
)
elif d < 322.5: # 315° — gentle three-quarter left-front
return (
"turned about 45° to her left. "
"Her face is partly toward the camera, right cheek and jaw more visible than left. "
"In the output image her face appears on the RIGHT half, nose angled toward the right edge. "
"Her right shoulder, right breast and right hip are angled toward the camera. "
"Her left side is starting to turn away"
)
elif d < 337.5: # 330° — slight left-front turn
return (
"turned slightly to her left — a subtle left-front view. "
"Both eyes visible, face still mostly toward the camera. "
"In the output image her face is nearly centered but noticeably shifted toward the RIGHT side. "
"Her right shoulder is clearly closer to the camera than her left"
)
else: # 345° — barely perceptible left-front tilt
return (
"facing almost directly toward the camera — just the subtlest hint of a left-front turn. "
"Both eyes and her full face are visible. "
"In the output image her face is nearly perfectly centered, "
"with only the tiniest tilt toward the RIGHT edge. "
"Her right shoulder is just a hair closer to the camera than her left. "
"This looks almost identical to a pure front view"
)
def yaw_prompt(deg: float) -> str:
"""Full prompt for one turntable angle."""
view = _angle_phrase(deg)
identity = _IDENTITY_FRONT if is_face_visible(deg) else _IDENTITY_BACK
return (
f"Redraw this person {view}. "
f"Keep everything identical — same person, same hair, same body, same lighting — "
f"only the camera viewing angle changes. {identity}."
)
def _angles_for(mode: str, n_views: int, sweep_deg: float) -> list:
"""Return the list of yaw angles to render."""
if mode == "turntable":
# Full 360, evenly spaced, loops cleanly
return [360.0 * i / n_views for i in range(n_views)]
elif mode == "swing":
# -sweep/2 .. +sweep/2 .. back (front-facing arc only — most reliable)
half = sweep_deg / 2.0
fwd = [(-half + sweep_deg * i / (n_views - 1)) for i in range(n_views)]
# map negatives into 0..360 turntable space (e.g. -45 -> 315)
return [a % 360 for a in fwd]
raise ValueError(f"Unknown mode: {mode!r}")
# ---------------------------------------------------------------------------
# 2. View generation (Qwen)
# ---------------------------------------------------------------------------
def _autocrop_alpha(pil: Image.Image, pad: int = 8) -> Image.Image:
"""Crop to the alpha bounding box (+pad) so every view is framed on the body."""
if pil.mode != "RGBA":
return pil
alpha = np.array(pil)[:, :, 3]
ys, xs = np.where(alpha > 16)
if len(xs) == 0:
return pil
x0, x1 = max(0, xs.min() - pad), min(pil.width, xs.max() + pad)
y0, y1 = max(0, ys.min() - pad), min(pil.height, ys.max() + pad)
return pil.crop((x0, y0, x1, y1))
def generate_views(
image_path: str,
output_dir: str,
n_views: int = 12,
seed: int = 42,
mode: str = "turntable",
sweep_deg: float = 180.0,
anchor: str = "original",
max_area: int = 0,
steps: int = 8,
on_progress=None,
) -> list:
"""
Render one Qwen view per yaw angle.
anchor='original' — every view edits the SAME front image (stable identity)
anchor='chain' — each view edits the previous result (smoother transitions,
but identity can drift over a full turn)
Returns list of dicts: {deg, path, pil}.
"""
os.makedirs(output_dir, exist_ok=True)
views_dir = os.path.join(output_dir, "views")
os.makedirs(views_dir, exist_ok=True)
start_pil = Image.open(image_path).convert("RGB")
is_front = is_front_view(start_pil)
if not is_front:
print(f"[orbit-qwen] Input image is NOT a representative front view. Generating a full front-view first...")
front_png = _run_pipeline(
start_pil, yaw_prompt(0.0), seed,
max_area or MAX_AREA,
steps=steps
)
base_pil = Image.open(io.BytesIO(front_png)).convert("RGB")
else:
base_pil = start_pil
angles = _angles_for(mode, n_views, sweep_deg)
results = []
prev_pil = None
completed_views_uncropped: dict[float, Image.Image] = {} # deg -> uncropped RGBA pil
for i, deg in enumerate(angles):
# If we pre-generated the front view and this is the 0° view, use it directly!
if not is_front and abs(deg) < 1e-3:
view_pil = base_pil.convert("RGBA")
completed_views_uncropped[deg] = view_pil
cropped_pil = _autocrop_alpha(view_pil)
path = os.path.join(views_dir, f"view_{i:03d}_{int(deg):03d}deg.png")
cropped_pil.save(path)
results.append({"deg": deg, "path": path, "pil": cropped_pil})
if anchor == "chain":
prev_pil = base_pil
continue
# Hybrid anchor strategy:
# Front/side views use the original front view.
# Back/rear views use the immediately preceding completed view.
if anchor == "chain":
src_pil = prev_pil if prev_pil is not None else base_pil
else:
# "original" anchor, but with our hybrid back-view chain:
if not is_face_visible(deg) and i > 0:
prev_angle = angles[i - 1]
src_pil = completed_views_uncropped[prev_angle].convert("RGB")
else:
src_pil = base_pil
prompt = yaw_prompt(deg)
if on_progress:
on_progress(i, len(angles), deg)
# Pass up to 2 already-generated views as extra references so Qwen can
# maintain identity/hair/clothing consistency across the full rotation.
extra_refs = None
if completed_views_uncropped:
def _angular_dist(a, b):
d = abs(a - b) % 360
return min(d, 360 - d)
target_visible = is_face_visible(deg)
eligible_views = {
a: pil for a, pil in completed_views_uncropped.items()
if is_face_visible(a) == target_visible
}
if eligible_views:
sorted_done = sorted(eligible_views.keys(),
key=lambda a: _angular_dist(a, deg))
extra_refs = [eligible_views[a].convert("RGB") for a in sorted_done[:2]]
png = _run_pipeline(
src_pil, prompt, seed,
max_area or MAX_AREA,
steps=steps,
extra_images=extra_refs,
)
view_pil = Image.open(io.BytesIO(png)).convert("RGBA")
completed_views_uncropped[deg] = view_pil
cropped_pil = _autocrop_alpha(view_pil)
path = os.path.join(views_dir, f"view_{i:03d}_{int(deg):03d}deg.png")
cropped_pil.save(path)
results.append({"deg": deg, "path": path, "pil": cropped_pil})
if anchor == "chain":
# Feed an RGB version forward (pipeline wants RGB anyway)
prev_pil = view_pil.convert("RGB")
return results
# ---------------------------------------------------------------------------
# 3. Smoothing — canvas-align + optical-flow interpolation
# ---------------------------------------------------------------------------
def _to_common_canvas(views: list, pad_frac: float = 0.12) -> list:
"""
Place every view on one fixed-size RGBA canvas, bottom-centered (feet anchored),
so the body doesn't jump frame-to-frame. Returns list of HxWx4 uint8 arrays.
"""
H = max(v["pil"].height for v in views)
W = max(v["pil"].width for v in views)
padH, padW = int(H * pad_frac), int(W * pad_frac)
CH, CW = H + 2 * padH, W + 2 * padW
out = []
for v in views:
p = v["pil"]
canvas = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
# bottom-centered: feet sit on a common baseline
x = (CW - p.width) // 2
y = CH - padH - p.height
canvas.paste(p, (x, y), p)
out.append(np.array(canvas))
return out
def _flow_morph_rgb(a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray:
"""
Optical-flow morph between two SOLID RGB frames (3-channel) at fraction t.
Operates on composited-over-bg images so there is no alpha halo/ghost.
Warps a→mid and b→mid, then blends.
"""
ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
flow_ab = cv2.calcOpticalFlowFarneback(ag, bg, None, 0.5, 5, 31, 5, 7, 1.5, 0)
flow_ba = cv2.calcOpticalFlowFarneback(bg, ag, None, 0.5, 5, 31, 5, 7, 1.5, 0)
H, W = ag.shape
yc, xc = np.mgrid[0:H, 0:W].astype(np.float32)
wa = cv2.remap(a, (xc + flow_ab[..., 0] * t), (yc + flow_ab[..., 1] * t),
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
wb = cv2.remap(b, (xc + flow_ba[..., 0] * (1 - t)), (yc + flow_ba[..., 1] * (1 - t)),
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
return (wa.astype(np.float32) * (1 - t) + wb.astype(np.float32) * t).clip(0, 255).astype(np.uint8)
def interpolate_views(
views: list,
factor: int = 4,
loop: bool = True,
smooth: bool = True,
bg: tuple = (18, 18, 18),
) -> list:
"""
Expand keyframes into a smooth sequence.
Keyframes are first composited over the solid bg, so all blending happens
in opaque RGB space — this removes the transparent-alpha ghosting that
plagued earlier flow morphs.
factor — intermediate frames per keyframe pair (1 = keyframes only)
loop — also blend last→first (seamless turntable)
smooth — optical-flow morph (True) vs simple crossfade (False)
Returns list of HxWx3 uint8 RGB frames.
"""
canvases = _to_common_canvas(views)
bg_arr = np.array(bg, dtype=np.float32)
def _flatten(rgba):
a = rgba[:, :, 3:4].astype(np.float32) / 255.0
return (rgba[:, :, :3].astype(np.float32) * a + bg_arr * (1 - a)).clip(0, 255).astype(np.uint8)
solid = [_flatten(c) for c in canvases]
if factor <= 1:
return solid
n = len(solid)
pairs = n if loop else n - 1
frames = []
for i in range(pairs):
a, b = solid[i], solid[(i + 1) % n]
frames.append(a)
for k in range(1, factor):
t = k / factor
if smooth:
frames.append(_flow_morph_rgb(a, b, t))
else:
frames.append((a.astype(np.float32) * (1 - t) +
b.astype(np.float32) * t).astype(np.uint8))
if not loop:
frames.append(solid[-1])
return frames
# ---------------------------------------------------------------------------
# 4. Video
# ---------------------------------------------------------------------------
def _composite_solid(frame: np.ndarray, bg=(18, 18, 18)) -> np.ndarray:
"""Accept RGB (already flattened) or RGBA; return BGR for ffmpeg."""
if frame.shape[2] == 3:
return cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
rgb = frame[:, :, :3].astype(np.float32)
a = frame[:, :, 3:4].astype(np.float32) / 255.0
bg_f = np.array(bg, dtype=np.float32)
out = (rgb * a + bg_f * (1 - a)).clip(0, 255).astype(np.uint8)
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
def build_video(frames: list, output_path: str, fps: int = 24, bg=(18, 18, 18)) -> None:
if not frames:
return
with tempfile.TemporaryDirectory(prefix="orbit_qwen_") as tmp:
for i, fr in enumerate(frames):
cv2.imwrite(os.path.join(tmp, f"f_{i:04d}.jpg"),
_composite_solid(fr, bg), [cv2.IMWRITE_JPEG_QUALITY, 95])
H, W = frames[0].shape[:2]
W2, H2 = W - (W % 2), H - (H % 2)
cmd = [
"ffmpeg", "-y", "-framerate", str(fps),
"-i", os.path.join(tmp, "f_%04d.jpg"),
"-vf", f"crop={W2}:{H2}:0:0",
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-crf", "18", "-movflags", "+faststart", output_path,
]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {r.stderr[-600:]}")
# ---------------------------------------------------------------------------
# 5. Orchestration
# ---------------------------------------------------------------------------
def run_qwen_orbit(
image_path: str,
output_dir: str,
n_views: int = 24,
seed: int = 42,
mode: str = "turntable",
sweep_deg: float = 180.0,
anchor: str = "original",
interp_factor: int = 1,
smooth: bool = False,
fps: int = 12,
max_area: int = 0,
steps: int = 8,
on_progress=None,
) -> dict:
"""
Full near-real turntable: generate Qwen views → align → MP4.
Defaults reflect the validated recipe: 24 crisp keyframes, NO blending,
12fps. Raise interp_factor only if you accept morph ghosting.
Returns dict: views (list), n_views, n_frames, video_path, views_dir.
"""
os.makedirs(output_dir, exist_ok=True)
views = generate_views(
image_path, output_dir,
n_views=n_views, seed=seed, mode=mode, sweep_deg=sweep_deg,
anchor=anchor, max_area=max_area, steps=steps, on_progress=on_progress,
)
loop = (mode == "turntable")
frames = interpolate_views(views, factor=interp_factor, loop=loop, smooth=smooth)
video_path = "" # MP4 not wanted, custom frame-loop used instead
return {
"views": [{"deg": v["deg"], "path": v["path"]} for v in views],
"n_views": len(views),
"n_frames": len(frames),
"video_path": "",
"views_dir": os.path.join(output_dir, "views"),
}

View File

@@ -0,0 +1,71 @@
#!/usr/bin/env python
"""
orbit_qwen_poc.py — near-real turntable test via Qwen-Image-Edit.
python orbit_qwen_poc.py --input front.png --output ./out \
--views 12 --mode turntable --interp 4 --seed 42
Generates one re-rendered view per yaw angle (anchored to the input, fixed seed),
flow-interpolates for smoothness, and stitches a looping MP4.
"""
import argparse
import os
import sys
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from orbit_qwen import run_qwen_orbit
def main():
ap = argparse.ArgumentParser(description="Qwen turntable (near-real subject turning).")
ap.add_argument("--input", "-i", required=True, help="Front-facing source image")
ap.add_argument("--output", "-o", default="./out_turntable", help="Output directory")
ap.add_argument("--views", "-v", type=int, default=24, help="Qwen keyframes (yaw steps)")
ap.add_argument("--mode", "-m", choices=["turntable", "swing"], default="turntable",
help="turntable=full 360 loop, swing=front-facing arc only")
ap.add_argument("--sweep", type=float, default=180.0, help="swing arc width in degrees")
ap.add_argument("--anchor", choices=["original", "chain"], default="original",
help="original=each view from source (stable), chain=from previous (smoother)")
ap.add_argument("--interp", type=int, default=1,
help="interpolated frames per keyframe gap (1=none; >1 ghosts, not advised)")
ap.add_argument("--no-smooth", action="store_true", help="crossfade instead of optical-flow morph")
ap.add_argument("--fps", type=int, default=12)
ap.add_argument("--seed", type=int, default=42)
ap.add_argument("--steps", type=int, default=8, help="Qwen sampler steps (4 fast, 8 nicer)")
ap.add_argument("--max-area", type=int, default=0, help="output pixel budget (0=API default)")
args = ap.parse_args()
if not os.path.exists(args.input):
print(f"[error] input not found: {args.input}", file=sys.stderr)
sys.exit(1)
def prog(i, n, deg):
print(f" [{i+1}/{n}] rendering {int(deg):3d}°…", flush=True)
print(f"[turntable] input : {args.input}")
print(f"[turntable] mode={args.mode} views={args.views} anchor={args.anchor} "
f"interp×{args.interp} seed={args.seed} steps={args.steps}")
t0 = time.perf_counter()
res = run_qwen_orbit(
image_path=args.input, output_dir=args.output,
n_views=args.views, seed=args.seed, mode=args.mode, sweep_deg=args.sweep,
anchor=args.anchor, interp_factor=args.interp, smooth=not args.no_smooth,
fps=args.fps, max_area=args.max_area, steps=args.steps, on_progress=prog,
)
dt = time.perf_counter() - t0
print(f"\n[turntable] done in {dt:.1f}s "
f"({dt/max(1,res['n_views']):.1f}s/view)")
print(f" keyframes : {res['n_views']}{res['views_dir']}")
print(f" frames : {res['n_frames']} (after interpolation)")
print(f" video : {res['video_path']}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,18 +0,0 @@
[Unit]
Description=Qwen-Image-Edit FastAPI Service
After=comfyui-backend.service
Requires=comfyui-backend.service
[Service]
Type=simple
User=__USER__
Group=__GROUP__
WorkingDirectory=__BASE__/api
ExecStart=/bin/bash __BASE__/api/start_api.sh
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -1,17 +0,0 @@
[Unit]
Description=ComfyUI Backend for Qwen-Image-Edit
After=network.target
[Service]
Type=simple
User=__USER__
Group=__GROUP__
WorkingDirectory=__BASE__
ExecStart=/bin/bash __BASE__/api/run_comfyui.sh
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
import argparse
import contextlib
import os
import re
import shutil
import sys
import tempfile
import traceback
from pathlib import Path
from unittest.mock import patch
IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp"]
def slug(value: str) -> str:
return re.sub(r"[^a-zA-Z0-9_.-]+", "_", value).strip("_") or "case"
def find_image(folder: Path, stem: str) -> Path | None:
for file in sorted(folder.iterdir()):
if file.is_file() and file.stem.lower() == stem.lower() and file.suffix.lower() in IMAGE_EXTS:
return file
return None
def find_cases(root: Path) -> list[dict]:
cases = []
for folder, _, _ in os.walk(root):
folder = Path(folder)
prompt_file = folder / "prompt.md"
image1 = find_image(folder, "image1")
image2 = find_image(folder, "image2")
if prompt_file.exists() and image1 and image2:
cases.append({
"folder": folder,
"image1": image1,
"image2": image2,
"prompt": prompt_file,
})
return cases
def expected_prompt_refinement(prompt: str) -> str:
# Match de intent uit je bestaande test:
# "image 1" / "Image 2" moet "Picture 1" / "Picture 2" worden.
prompt = re.sub(r"\bimage\s+1\b", "Picture 1", prompt, flags=re.IGNORECASE)
prompt = re.sub(r"\bimage\s+2\b", "Picture 2", prompt, flags=re.IGNORECASE)
return prompt
def run_case(edit_api, HTTPException, case: dict, output_dir: Path, check_refinement: bool) -> tuple[bool, str]:
folder = case["folder"]
case_name = slug(folder.relative_to(folder.parents[0]).as_posix())
model_filename = f"{case_name}__image1{case['image1'].suffix.lower()}"
scene_filename = f"{case_name}__image2{case['image2'].suffix.lower()}"
model_path = output_dir / model_filename
scene_path = output_dir / scene_filename
shutil.copy2(case["image1"], model_path)
shutil.copy2(case["image2"], scene_path)
prompt = case["prompt"].read_text(encoding="utf-8").strip()
saved_prompts = []
def mock_save_db_prompt(ptype, prompt_text, meta):
saved_prompts.append(prompt_text)
# Test 1: missing model image -> 404
req_missing = edit_api.SceneryRequest(
model_filename=f"missing_{case_name}.png",
scene_image=scene_filename,
prompt=prompt,
)
try:
edit_api.generate_scenery(req_missing)
return False, "Missing-model check faalde: er kwam geen HTTPException."
except HTTPException as exc:
if exc.status_code != 404:
return False, f"Missing-model check gaf status {exc.status_code}, verwacht 404."
if "Model image not found" not in str(exc.detail):
return False, f"Missing-model check detail onverwacht: {exc.detail}"
# Test 2: model en scene zijn hetzelfde bestand -> 400
req_identical = edit_api.SceneryRequest(
model_filename=model_filename,
scene_image=model_filename,
prompt=prompt,
)
try:
edit_api.generate_scenery(req_identical)
return False, "Identical-image check faalde: er kwam geen HTTPException."
except HTTPException as exc:
if exc.status_code != 400:
return False, f"Identical-image check gaf status {exc.status_code}, verwacht 400."
if "cannot be the same image" not in str(exc.detail):
return False, f"Identical-image check detail onverwacht: {exc.detail}"
# Test 3: geldige case, prompt refinement controleren, geen echte thread starten
req_valid = edit_api.SceneryRequest(
model_filename=model_filename,
scene_image=scene_filename,
prompt=prompt,
)
with patch("edit_api._load_wireframe_dir", return_value=str(output_dir), create=True), \
patch("edit_api.database.save_db_prompt", side_effect=mock_save_db_prompt), \
patch("threading.Thread.start"):
edit_api.generate_scenery(req_valid)
if len(saved_prompts) != 1:
return False, f"save_db_prompt werd {len(saved_prompts)} keer aangeroepen, verwacht 1."
if check_refinement:
expected = expected_prompt_refinement(prompt)
actual = saved_prompts[0]
if actual != expected:
return False, (
"Prompt refinement klopt niet.\n"
f"Expected: {expected!r}\n"
f"Actual: {actual!r}"
)
return True, "OK"
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--cases-dir",
default="scenery_cases",
help="Root-folder met subfolders die image1.*, image2.* en prompt.md bevatten.",
)
parser.add_argument(
"--output-dir",
default=None,
help="Optioneel vaste output-dir. Default gebruikt een tijdelijke map.",
)
parser.add_argument(
"--no-refinement-check",
action="store_true",
help="Alleen controleren dat generate_scenery werkt, niet de exacte prompt-output.",
)
args = parser.parse_args()
project_root = Path.cwd()
sys.path.insert(0, str(project_root))
import edit_api
from fastapi import HTTPException
cases_root = Path(args.cases_dir).resolve()
if not cases_root.exists():
print(f"ERROR: cases-dir bestaat niet: {cases_root}")
return 2
cases = find_cases(cases_root)
if not cases:
print(f"Geen testcases gevonden onder: {cases_root}")
print("Verwachte structuur per testcase:")
print(" some_case/")
print(" image1.png")
print(" image2.png")
print(" prompt.md")
return 2
if args.output_dir:
output_dir = Path(args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
cleanup = contextlib.nullcontext(output_dir)
else:
cleanup = tempfile.TemporaryDirectory(prefix="scenery_validation_")
passed = 0
failed = 0
with cleanup as tmp:
output_dir = Path(tmp) if not isinstance(tmp, Path) else tmp
print(f"Cases root : {cases_root}")
print(f"Output dir : {output_dir}")
print(f"Cases : {len(cases)}")
print()
for case in cases:
label = case["folder"].relative_to(cases_root)
try:
ok, message = run_case(
edit_api=edit_api,
HTTPException=HTTPException,
case=case,
output_dir=output_dir,
check_refinement=not args.no_refinement_check,
)
if ok:
passed += 1
print(f"[PASS] {label}")
else:
failed += 1
print(f"[FAIL] {label}")
print(message)
print()
except Exception:
failed += 1
print(f"[ERROR] {label}")
traceback.print_exc()
print()
print()
print(f"Resultaat: {passed} passed, {failed} failed")
return 0 if failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Test script to generate a draft orbit of 12 images.
Allows us to verify prompts and consistency across left and right sides.
"""
import os
import sys
import time
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from orbit_qwen import run_qwen_orbit
def main():
# "/mnt/zim/tour-comfy/output/20260625_045029_pose_3_20260618_173728_image.png"
input_image = "/mnt/zim/tour-comfy/output/20260625_045029_pose_3_20260618_173728_image.png"
output_dir = "/home/mike/dev/qwen-image-edit-rapid-aio-nsfw-v23/test/orbit_360_test_13"
if not os.path.exists(input_image):
print(f"Error: input image not found: {input_image}")
sys.exit(1)
print(f"Generating 12-view orbit for: {input_image}")
print(f"Output directory: {output_dir}")
t0 = time.perf_counter()
res = run_qwen_orbit(
image_path=input_image,
output_dir=output_dir,
n_views=12,
seed=42,
mode="turntable",
anchor="original",
interp_factor=1,
steps=4, # Fast draft steps (4)
on_progress=lambda i, n, deg: print(f" [{i + 1}/{n}] rendering {int(deg):3d}°...", flush=True)
)
dt = time.perf_counter() - t0
print(f"Done in {dt:.1f}s")
print(f"Views generated at: {res['views_dir']}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import os
import sys
import time
import unittest
from fastapi.testclient import TestClient
# Ensure tour-comfy is in the import path
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.append(_HERE)
from edit_api import app, _load_output_dir
class TestQwenBackendRegression(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.client = TestClient(app)
cls.output_dir = _load_output_dir()
# Real-data filenames specified in the issue description
cls.real_img_1 = "20260618_053519_1_20260618_053458_image.png"
cls.real_img_2 = "20260618_052537_0_20260618_052526_image.png"
cls.wireframe_img = "up_20260628_104641_image.png"
# Validate that they exist on disk to run real end-to-end tests
cls.run_real_tests = True
img1_path = os.path.join(cls.output_dir, cls.real_img_1)
img2_path = os.path.join(cls.output_dir, cls.real_img_2)
if not os.path.exists(img1_path) or not os.path.exists(img2_path):
cls.run_real_tests = False
print(f"[test] Real images not found at {img1_path} or {img2_path}. Running tests with fallback/mock assertion modes.")
def test_multi_ref_qwen_endpoint(self):
"""Test multi-reference generation endpoint with specified real-data images."""
if not self.run_real_tests:
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
payload = {
"filenames": [self.real_img_1, self.real_img_2],
"prompt": "standing girl looking at camera, high quality, highly detailed",
"seed": 42,
"max_area": 512 * 512,
"pad_outpaint": False
}
response = self.client.post("/multi-ref", json=payload)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("job_id", data)
job_id = data["job_id"]
# Poll status until done (limit to 300 seconds to prevent hanging if backend is offline)
print(f"[test] Polling multi-ref job {job_id} until completion...")
start_time = time.time()
completed = False
while time.time() - start_time < 300:
status_resp = self.client.get(f"/batch/{job_id}")
if status_resp.status_code == 200:
job_data = status_resp.json()
status = job_data.get("status")
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
if status == "done":
completed = True
break
elif status in ["error", "cancelled"]:
break
time.sleep(3)
self.assertTrue(completed, "Multi-ref generation job did not complete successfully in time.")
def test_scenery_qwen_endpoint(self):
"""Test scenery generation endpoint with specified wireframe scene image and prompt."""
if not self.run_real_tests:
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
payload = {
"model_filename": self.real_img_1,
"scene_image": self.wireframe_img,
"prompt": "replace person from image 1 naturally with the person from Image 2. Keep the exact position of Image 1, and the exact person of Image 2",
"seed": 42
}
response = self.client.post("/generate-scenery", json=payload)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("job_id", data)
job_id = data["job_id"]
# Poll status until done
print(f"[test] Polling scenery job {job_id} until completion...")
start_time = time.time()
completed = False
while time.time() - start_time < 300:
status_resp = self.client.get(f"/batch/{job_id}")
if status_resp.status_code == 200:
job_data = status_resp.json()
status = job_data.get("status")
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
if status == "done":
completed = True
break
elif status in ["error", "cancelled"]:
break
time.sleep(3)
self.assertTrue(completed, "Scenery generation job did not complete successfully in time.")
if __name__ == "__main__":
unittest.main()

286
tour-comfy/trash.html Normal file
View File

@@ -0,0 +1,286 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Qwen Rapid-AIO — Database Consistency</title>
<style>
body {
background: #111;
color: #ccc;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
}
h1, h2 { color: #eee; }
.container { max-width: 1000px; margin: 0 auto; }
.section {
background: #1a1a1a;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #333;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid #222;
}
.item:last-child { border-bottom: none; }
.filename { font-family: monospace; color: #aaa; }
.info { font-size: 0.9em; color: #777; }
.btn {
background: #333;
color: #eee;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.btn:hover { background: #444; }
.btn.danger { background: #633; }
.btn.danger:hover { background: #844; }
.btn.success { background: #363; }
.btn.success:hover { background: #484; }
.btn.primary { background: #35a; font-weight: bold; }
.btn.primary:hover { background: #46b; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.nav { margin-bottom: 20px; }
.nav a { color: #58a; text-decoration: none; font-size: 0.9em; }
.nav a:hover { text-decoration: underline; }
#toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #333;
color: #fff;
padding: 10px 20px;
border-radius: 4px;
display: none;
z-index: 1000;
}
</style>
</head>
<body>
<div class="container">
<div class="nav">
<a href="car.html">← Back to Studio</a>
</div>
<div class="header">
<h1>Database Consistency</h1>
<button class="btn primary" onclick="loadInconsistencies(true)">Run Check Now</button>
</div>
<div id="loading" style="display:none">Running consistency check...</div>
<div class="section">
<h2>Archived Items <span class="info">(Manually archived, recoverable)</span></h2>
<div id="archivedList">No archived items found.</div>
</div>
<div class="section">
<h2>Missing Group IDs <span class="info">(Legacy or orphaned images with no group assigned)</span></h2>
<div id="orphansList">No images with missing groups found.</div>
</div>
<div class="section">
<h2>Orphaned Records <span class="info">(In DB, but file missing on disk)</span></h2>
<div id="missingList">No orphaned records found.</div>
</div>
<div class="section">
<h2>Untracked Files <span class="info">(On disk, but not in DB)</span></h2>
<div id="untrackedList">No untracked files found.</div>
</div>
<div class="section">
<h2>Metadata & Pose Deep Backfill <span class="info">(Re-analyze pose, anatomical completeness, gaze, and objects with detailed geometry rules)</span></h2>
<div style="display:flex;gap:12px;margin-top:10px">
<button class="btn danger" onclick="invalidateMetadata()">Invalidate All Old Metadata</button>
<button class="btn success" onclick="triggerBackfill()">Trigger Deep Backfill Now</button>
</div>
<div id="backfillStatus" style="margin-top:12px;font-size:0.9em;color:#aaa"></div>
</div>
<div class="info" id="timestamp"></div>
</div>
<div id="toast"></div>
<script>
let API = window.location.origin;
if (API === 'null' || API.startsWith('file://')) {
API = 'http://127.0.0.1:8500';
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 3000);
}
async function repair(filename, action) {
try {
const r = await fetch(`${API}/db/repair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, action })
});
const d = await r.json();
let msg = 'Done';
if (d.status === 'deleted') msg = 'Record deleted';
else if (d.status === 'imported') msg = 'File imported';
else if (d.status === 'restored') msg = 'Item restored';
else if (d.status === 'deleted_permanently') msg = 'Permanently deleted';
else if (d.status === 'assigned') msg = 'Group assigned';
showToast(msg);
loadInconsistencies();
} catch (e) {
console.error(e);
showToast('Action failed');
}
}
async function loadInconsistencies(runNow = false) {
if (runNow) document.getElementById('loading').style.display = 'block';
try {
const url = runNow ? `${API}/db/inconsistencies?run_now=true` : `${API}/db/inconsistencies`;
const r = await fetch(url);
const d = await r.json();
renderArchived(d.archived_items || []);
renderMissing(d.missing_files || []);
renderUntracked(d.untracked_files || []);
renderOrphans(d.missing_group || []);
if (d.timestamp) {
const date = new Date(d.timestamp * 1000);
document.getElementById('timestamp').textContent = 'Last check: ' + date.toLocaleString();
}
} catch (e) {
console.error(e);
showToast('Failed to load inconsistencies');
} finally {
document.getElementById('loading').style.display = 'none';
}
}
function renderOrphans(files) {
const container = document.getElementById('orphansList');
if (files.length === 0) {
container.innerHTML = 'No images with missing groups found.';
return;
}
container.innerHTML = files.map(f => `
<div class="item">
<div style="display:flex;align-items:center">
<img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div>
<div class="filename">${f.filename}</div>
<div class="info">Name: ${f.name || 'none'}</div>
</div>
</div>
<button class="btn success" onclick="repair('${f.filename}', 'assign_group')">Auto-assign Group</button>
</div>
`).join('');
}
function renderArchived(files) {
const container = document.getElementById('archivedList');
if (files.length === 0) {
container.innerHTML = 'No archived items found.';
return;
}
container.innerHTML = files.map(f => `
<div class="item">
<div style="display:flex;align-items:center">
<img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div>
<div class="filename">${f.filename}</div>
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn success" onclick="repair('${f.filename}', 'restore')">Restore</button>
<button class="btn danger" onclick="if(confirm('Permanently delete ${f.filename}?')) repair('${f.filename}', 'delete_permanently')">Delete</button>
</div>
</div>
`).join('');
}
function renderMissing(files) {
const container = document.getElementById('missingList');
if (files.length === 0) {
container.innerHTML = 'No orphaned records found.';
return;
}
container.innerHTML = files.map(f => `
<div class="item">
<div style="display:flex;align-items:center">
<img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div>
<div class="filename">${f.filename}</div>
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
</div>
</div>
<button class="btn danger" onclick="repair('${f.filename}', 'delete_record')">Delete Record</button>
</div>
`).join('');
}
function renderUntracked(files) {
const container = document.getElementById('untrackedList');
if (files.length === 0) {
container.innerHTML = 'No untracked files found.';
return;
}
container.innerHTML = files.map(f => `
<div class="item">
<div style="display:flex;align-items:center">
<img src="${API}/output/${encodeURIComponent(f)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div class="filename">${f}</div>
</div>
<button class="btn success" onclick="repair('${f}', 'import_file')">Import File</button>
</div>
`).join('');
}
async function invalidateMetadata() {
if (!confirm("Are you sure you want to invalidate all earlier metadata? This will clear all calculated completeness/pose/gaze/object details, enabling them to be clean-reprocessed by the background worker or manual backfiller.")) return;
try {
const r = await fetch(`${API}/images/invalidate-metadata`, { method: 'POST' });
const d = await r.json();
showToast(d.message || "Metadata invalidated successfully");
document.getElementById('backfillStatus').textContent = "All metadata reset. Background idle backfill will now start processing them one by one.";
} catch (e) {
console.error(e);
showToast('Failed to invalidate metadata');
}
}
async function triggerBackfill() {
document.getElementById('backfillStatus').textContent = "Triggering manual deep backfill on all images in background...";
try {
const r = await fetch(`${API}/images/backfill-metadata`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true })
});
const d = await r.json();
showToast("Deep backfill complete!");
document.getElementById('backfillStatus').textContent = `Backfill complete! Processed: ${d.processed}, Failed: ${d.failed}, Total: ${d.total}.`;
} catch (e) {
console.error(e);
showToast('Backfill failed');
document.getElementById('backfillStatus').textContent = "Manual backfill failed or timed out.";
}
}
loadInconsistencies();
</script>
</body>
</html>

View File

@@ -0,0 +1,171 @@
"""
turntable_cache.py — persistent state for Qwen turntable generation.
State stored as JSON: {output_dir}/_turntable/{group_id}/state.json
Views stored alongside: {output_dir}/_turntable/{group_id}/views/view_NNN_DDDdeg.png
Final video: {output_dir}/_turntable/{group_id}/turntable.mp4
One state file per group tracks completed angles so background generation can
resume after restart without re-rendering anything that's already on disk.
"""
import os
import json
import time
from typing import Optional
_HERE = os.path.dirname(os.path.abspath(__file__))
def cache_dir(output_dir: str, group_id: str) -> str:
return os.path.join(output_dir, "_turntable", str(group_id))
def state_path(output_dir: str, group_id: str) -> str:
return os.path.join(cache_dir(output_dir, group_id), "state.json")
def load_state(output_dir: str, group_id: str) -> Optional[dict]:
try:
import database
db_state = database.load_turntable(group_id)
if db_state:
return db_state
except Exception as e:
print(f"[turntable_cache] DB load error: {e}")
p = state_path(output_dir, group_id)
if not os.path.exists(p):
return None
try:
with open(p) as f:
return json.load(f)
except Exception:
return None
def save_state(output_dir: str, group_id: str, state: dict):
os.makedirs(cache_dir(output_dir, group_id), exist_ok=True)
p = state_path(output_dir, group_id)
tmp = p + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f, indent=2)
os.replace(tmp, p) # atomic
try:
import database
database.save_turntable(group_id, state)
except Exception as e:
print(f"[turntable_cache] DB save error: {e}")
def init_state(
output_dir: str,
group_id: str,
source_image: str,
preferred_filename: str,
n_views: int = 24,
seed: int = 42,
steps: int = 8,
) -> dict:
"""Create a fresh state dict and save it. Wipes any existing partial state."""
import sys
if _HERE not in sys.path:
sys.path.insert(0, _HERE)
from orbit_qwen import _angles_for
angles = _angles_for("turntable", n_views, 180.0)
state = {
"group_id": str(group_id),
"preferred_filename": preferred_filename,
"source_image": source_image,
"seed": seed,
"n_views": n_views,
"steps": steps,
"angles": angles,
"views": {}, # deg_key (str) -> abs path
"video_path": None,
"completed": False,
"started_at": time.time(),
"completed_at": None,
}
save_state(output_dir, group_id, state)
return state
def deg_key(deg: float) -> str:
return f"{deg:.1f}"
def mark_view_done(output_dir: str, group_id: str, state: dict, deg: float, path: str):
state["views"][deg_key(deg)] = path
save_state(output_dir, group_id, state)
def mark_completed(output_dir: str, group_id: str, state: dict, video_path: str):
state["completed"] = True
state["video_path"] = video_path
state["completed_at"] = time.time()
save_state(output_dir, group_id, state)
def next_missing_angle(state: dict) -> Optional[float]:
"""Return first angle not yet in state['views'], or None if all done."""
done = state.get("views", {})
for deg in state.get("angles", []):
if deg_key(deg) not in done:
return deg
return None
def list_cached_group_ids(output_dir: str) -> list:
td = os.path.join(output_dir, "_turntable")
if not os.path.isdir(td):
return []
return [
d for d in os.listdir(td)
if os.path.isfile(os.path.join(td, d, "state.json"))
]
def get_status_summary(output_dir: str) -> dict:
"""Return {group_id: status_dict} for all groups that have a state file."""
result = {}
for gid in list_cached_group_ids(output_dir):
st = load_state(output_dir, gid)
if st:
result[gid] = {
"completed": st.get("completed", False),
"n_done": len(st.get("views", {})),
"n_total": st.get("n_views", 24),
"video_path": st.get("video_path"),
"preferred_filename": st.get("preferred_filename"),
"started_at": st.get("started_at"),
"completed_at": st.get("completed_at"),
}
return result
def delete_state(output_dir: str, group_id: str):
"""Wipe all cached views, state, and video for this group."""
import shutil
d = cache_dir(output_dir, group_id)
if os.path.isdir(d):
shutil.rmtree(d)
try:
import database
database.delete_turntable(group_id)
except Exception as e:
print(f"[turntable_cache] DB delete error: {e}")
def get_group_video(output_dir: str, group_id: str) -> Optional[str]:
"""Return the video path if the turntable is complete and the file exists."""
st = load_state(output_dir, group_id)
if not st or not st.get("completed"):
return None
vp = st.get("video_path")
if vp and os.path.exists(vp):
return vp
return None

115
xhamster_downloader.py Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
XHamster Video Downloader using yt-dlp
This script downloads videos from xhamster2.com using the yt-dlp library.
Please be aware of the legal and ethical considerations when downloading content.
Usage:
python xhamster_downloader.py <video_url>
Example:
python xhamster_downloader.py "https://xhamster2.com/videos/step-sis-will-do-anything-to-make-me-delete-this-videos-xhGt5fJ"
"""
import sys
import os
import yt_dlp
import logging
import argparse
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('xhamster_downloader.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
def download_xhamster_video(url, output_path=None, quality="720p"):
"""
Download a video from xhamster2.com using yt-dlp
Args:
url (str): The URL of the xhamster video to download
output_path (str): Optional custom output path
quality (str): Video quality (default: 720p)
Returns:
bool: True if successful, False otherwise
"""
# Format selector configuration
format_selector = 'best'
if quality != 'best':
if quality.endswith('p'):
height = quality[:-1]
format_selector = f'bestvideo[height<={height}]+bestaudio/best[height<={height}]'
else:
format_selector = quality
# Configure yt-dlp options
ydl_opts = {
'format': format_selector,
'outtmpl': output_path or '%(title)s.%(ext)s',
'noplaylist': True, # Download only the video, not playlists
'nocheckcertificate': True, # Skip SSL certificate verification if needed
'verbose': False, # Set to True for detailed logging
'progress_hooks': [lambda d: logger.info(f"Progress: {d['status']} - {d.get('downloaded_bytes', 0)}/{d.get('total_bytes', 'unknown')} bytes")],
'postprocessors': [{
'key': 'FFmpegVideoConvertor',
'preferedformat': 'mp4'
}],
}
try:
logger.info(f"Starting download for: {url}")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
logger.info("Download completed successfully!")
return True
except yt_dlp.utils.DownloadError as e:
logger.error(f"Download error: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error: {e}")
return False
def main():
"""Main function to handle command line arguments and execute download"""
parser = argparse.ArgumentParser(description="XHamster Video Downloader")
parser.add_argument("url", help="URL of the video to download")
parser.add_argument("-q", "--quality", default="720p", help="Video quality (e.g. best, 1080p, 720p, 480p) (default: 720p)")
args = parser.parse_args()
video_url = args.url
# Validate URL
if not video_url.startswith("http"):
print("Error: Please provide a valid URL starting with http:// or https://")
sys.exit(1)
logger.info("XHamster Video Downloader Started")
logger.info(f"Target URL: {video_url}")
success = download_xhamster_video(video_url, quality=args.quality)
if success:
logger.info("Download process completed successfully!")
print("Video downloaded successfully!")
else:
logger.error("Download process failed!")
print("Failed to download video.")
sys.exit(1)
if __name__ == "__main__":
main()