Compare commits

..

16 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
39 changed files with 9003 additions and 1184 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}")
@@ -251,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}")

View File

@@ -63,6 +63,14 @@ FastAPI :8500 (edit_api.py)
- **Systemd**: `comfyui-api`
- **DB**: PostgreSQL at `192.168.1.160:5433`, database `dv`, table `person`
#### 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
@@ -112,6 +120,12 @@ FastAPI :8500 (edit_api.py)
| `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 |
@@ -192,11 +206,13 @@ prompt
│ set negative: "deformed anatomy, watermark, logo"
├─ Canvas Expansion / Outpainting requested?
│ YES → if outpaint=True, append/use natural outpainting instructions:
"Naturally outpaint and extend the borders of the image to complete the scene."
│ 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)
@@ -315,6 +331,9 @@ Model: `buffalo_l`
| 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 |
---
@@ -352,10 +371,51 @@ To prevent desyncs during image transitions, crops, canvas expansion, or 2.2x zo
- 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`)

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

@@ -15,8 +15,15 @@ NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -t
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

@@ -56,10 +56,10 @@ Scenery:
- ✅ 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, 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
- 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.
@@ -68,15 +68,47 @@ Scenery:
- ✅ 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
## refine
- 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
@@ -100,3 +132,45 @@ videos
2) 3ref image MERGE
3) 3ref straight on- head-on - looking viewer
ok cool,
Let do some work in the "Info" tab.
Pose information
<usefull info is here>
GAZE: -
BODY: -
SUBJECTS: -
TRACKING
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
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.)
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

@@ -1,87 +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

@@ -19,7 +19,7 @@ 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:-16384}" # Q8 has headroom; bump context from 4096
CTX="${CTX:-32768}" # Q8 has headroom; bump context from 4096
NGL="${NGL:-99}" # all layers on GPU (24GB < 32GB VRAM)
ACTION="${1:-deploy}"

View File

@@ -56,14 +56,15 @@ 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 — black void sceneries — restraining pose directions for adult (NSFW) nude female photography. "
"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 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, keep the characteristics of the reference image'. "
"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."
@@ -106,10 +107,10 @@ def main():
# Few-shot: select examples with at least 600 characters, prioritizing those that meet the criteria
items = list(existing.items())
# 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")

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.

View File

View File

@@ -21,6 +21,8 @@ Env:
import asyncio
import json
import os
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
import subprocess
import threading
import time
@@ -69,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
@@ -78,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

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,36 +0,0 @@
SYSTEM2 = (
"You write explicit photographic — mechanical — black void — Optimal immobilization — restraining pose directions for adult (NSFW) nude female photography. "
# "You write poses containing labial spreaders and mouth spreaders. "
"You write poses that Describe high quality, high detail, add a chirurgical steel labial spreader device - clamping only at outer labia - coming from inner labia - fully inserted, full access for examination and sexual stimulant, full spread,show maximum labial area, detailed teenage nude. "
"The subject's hands and feet must be restrained and immobilized. "
# "You write explicit photographic — mechanical — black void — Optimal immobilization — restraining pose directions for adult (NSFW) nude female photography. "
#"You write poses containing labial spreaders and mouth spreaders. "
# "You write poses that Describe high quality, high detail, add a chirurgical steel labial spreader device - clamping only at outer labia - coming from inner labia - fully inserted, full access for examination and sexual stimulant, full spread,show maximum labial area, hyper realistic, perfect anatomy, detailed nude, keep the characteristics of the reference image "
"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> — <Specialization>' (no colon, no 'The' required), "
"followed by 7-11 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, keep the characteristics of the reference image'. "
"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."
)
SYSTEM3 = (
"You write explicit photographic — mechanical — black void sceneries — Optimal immobilization — restraining pose directions for adult (NSFW) nude female photography. "
# "You write poses containing labial spreaders and mouth spreaders. "
"You write poses that Describe high quality, high detail, add a chirurgical steel labial spreader device - clamping only at outer labia - coming from inner labia - fully inserted, full access for examination and sexual stimulant, full spread,show maximum labial area, detailed teenage nude. "
"The subject's hands and feet must be restrained and immobilized. "
# "You write explicit photographic — mechanical — black void — Optimal immobilization — restraining pose directions for adult (NSFW) nude female photography. "
#"You write poses containing labial spreaders and mouth spreaders. "
# "You write poses that Describe high quality, high detail, add a chirurgical steel labial spreader device - clamping only at outer labia - coming from inner labia - fully inserted, full access for examination and sexual stimulant, full spread,show maximum labial area, hyper realistic, perfect anatomy, detailed nude, keep the characteristics of the reference image "
"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 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, keep the characteristics of the reference image'. "
"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."
)

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"api_url": "http://127.0.0.1:8500/edit",
"base_prompts": [
"masterpiece. high quality. hyper realistic. detailed, detailed skin",
"detailed female nude. realistic, detailed teenage female nude. realistic",
"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.",

View File

@@ -2,6 +2,8 @@ import psycopg2
from psycopg2 import pool as _pgpool
import threading
import json
import time
import random
DB_CONFIG = {
"host": "192.168.1.160",
@@ -99,6 +101,34 @@ def migrate_schema():
conn = get_db_connection()
cur = conn.cursor()
try:
# Create prompt table first
cur.execute("""
CREATE TABLE IF NOT EXISTS prompt (
id SERIAL PRIMARY KEY,
type TEXT NOT NULL,
prompt_text TEXT NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_prompt_type_text UNIQUE (type, prompt_text)
)
""")
# Create turntable table
cur.execute("""
CREATE TABLE IF NOT EXISTS turntable (
group_id TEXT PRIMARY KEY,
preferred_filename TEXT,
source_image TEXT,
seed INTEGER,
n_views INTEGER,
steps INTEGER,
video_path TEXT,
completed BOOLEAN DEFAULT FALSE,
started_at DOUBLE PRECISION,
completed_at DOUBLE PRECISION,
angles TEXT, -- JSON list of angles
views TEXT -- JSON representation of views dict
)
""")
for sql in [
"ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT",
@@ -113,6 +143,13 @@ def migrate_schema():
"ALTER TABLE person ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS face_embedding vector(512)",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS is_source BOOLEAN DEFAULT FALSE",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose_description TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose_skeleton TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS people_count INTEGER DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS anatomical_completeness BOOLEAN DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS facial_direction TEXT DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS objects JSONB DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS description TEXT",
]:
cur.execute(sql)
conn.commit()
@@ -123,6 +160,10 @@ def migrate_schema():
initialize_tags_in_db()
except Exception as e:
print(f"[db] initialize_tags_in_db error: {e}")
try:
migrate_turntable_states_from_disk_to_db()
except Exception as e:
print(f"[db] migrate_turntable_states_from_disk_to_db error: {e}")
def initialize_tags_in_db():
conn = get_db_connection()
@@ -195,6 +236,14 @@ def initialize_tags_in_db():
if "BACKGROUND_REMOVED" in tags_list:
tags_list.remove("BACKGROUND_REMOVED")
# ORBIT tag logic: only true orbits are tagged with ORBIT
is_orb = filename.startswith("_turntable/")
if is_orb:
add_tag_if_missing("ORBIT")
else:
if "ORBIT" in tags_list:
tags_list.remove("ORBIT")
cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags_list), filename))
conn.commit()
finally:
@@ -206,7 +255,29 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
sort_order=None, group_name=None, hidden=None,
has_background=None, source_refs=None, has_clothing=None,
content_type=None, faceswap_source_video=None, archived=None,
face_embedding=None, is_source=None):
face_embedding=None, is_source=None,
pose_description=None, pose_skeleton=None,
people_count=None, anatomical_completeness=None, facial_direction=None, objects=None,
description=None):
if tags is not None:
if isinstance(tags, str):
try:
tags_list = json.loads(tags)
except Exception:
tags_list = []
elif isinstance(tags, list):
tags_list = tags
else:
tags_list = []
# Enforce that ORBIT is only on _turntable/ files
if filename.startswith("_turntable/"):
if "ORBIT" not in tags_list:
tags_list.append("ORBIT")
else:
tags_list = [t for t in tags_list if t != "ORBIT"]
tags = tags_list
conn = get_db_connection()
cur = conn.cursor()
face_embedding_str = ("[" + ",".join(map(str, face_embedding)) + "]") if face_embedding is not None else None
@@ -215,8 +286,10 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
INSERT INTO person (filename, filepath, name, group_id, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing,
content_type, faceswap_source_video, archived, face_embedding, is_source)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
content_type, faceswap_source_video, archived, face_embedding, is_source,
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction, objects,
description)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (filename) DO UPDATE
SET filepath = COALESCE(EXCLUDED.filepath, person.filepath),
name = COALESCE(EXCLUDED.name, person.name),
@@ -236,13 +309,28 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video),
archived = COALESCE(EXCLUDED.archived, person.archived),
face_embedding = COALESCE(EXCLUDED.face_embedding, person.face_embedding),
is_source = COALESCE(EXCLUDED.is_source, person.is_source);
is_source = COALESCE(EXCLUDED.is_source, person.is_source),
pose_description = COALESCE(EXCLUDED.pose_description, person.pose_description),
pose_skeleton = COALESCE(EXCLUDED.pose_skeleton, person.pose_skeleton),
people_count = COALESCE(EXCLUDED.people_count, person.people_count),
anatomical_completeness = COALESCE(EXCLUDED.anatomical_completeness, person.anatomical_completeness),
facial_direction = COALESCE(EXCLUDED.facial_direction, person.facial_direction),
objects = COALESCE(EXCLUDED.objects, person.objects),
description = COALESCE(EXCLUDED.description, person.description);
""", (filename, filepath, name, group_id,
json.dumps(tags) if tags else None,
embedding, clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing,
content_type, faceswap_source_video, archived, face_embedding_str, is_source))
content_type, faceswap_source_video, archived, face_embedding_str, is_source,
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction,
json.dumps(objects) if objects else None,
description))
conn.commit()
# Sync after commit
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -253,6 +341,10 @@ def set_archived(filename, archived: bool):
try:
cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename))
conn.commit()
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -267,6 +359,11 @@ def set_filenames_archived(filenames, archived: bool):
rows = cur.fetchall()
updated = [r[0] for r in rows]
conn.commit()
for fn in updated:
try:
sync_by_filename_async(fn)
except Exception:
pass
return updated
finally:
cur.close()
@@ -278,6 +375,10 @@ def set_hidden(filename, hidden: bool):
try:
cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename))
conn.commit()
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -288,6 +389,10 @@ def set_person_tags(filename, tags):
try:
cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename))
conn.commit()
try:
sync_by_filename_async(filename)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -299,7 +404,8 @@ def get_person(filename):
cur.execute("""
SELECT name, group_id, tags, embedding, clip_description, filepath,
prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
has_clothing, is_source
has_clothing, is_source, pose_description, pose_skeleton,
people_count, anatomical_completeness, facial_direction, objects, description
FROM person WHERE filename = %s
""", (filename,))
return cur.fetchone()
@@ -315,7 +421,9 @@ def list_persons(include_archived=False):
cur.execute(f"""
SELECT filename, name, group_id, clip_description,
prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
has_clothing, content_type, faceswap_source_video, archived, is_source, tags
has_clothing, content_type, faceswap_source_video, archived, is_source, tags,
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction, objects, description,
filepath
FROM person
{where}
""")
@@ -342,21 +450,56 @@ def search_similar(embedding, limit=10):
_put_db_connection(conn)
def delete_person(filename):
person = get_person(filename)
gid = person[1] if person else None
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM person WHERE filename = %s", (filename,))
conn.commit()
# Delete JSON file on disk
try:
out_dir = _get_output_dir()
base, _ = os.path.splitext(filename)
json_path = os.path.join(out_dir, base + ".json")
if os.path.exists(json_path):
os.remove(json_path)
except Exception:
pass
if gid:
try:
sync_group_to_json_async(gid)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
def delete_group(group_id):
files = get_group_files(group_id)
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,))
conn.commit()
# Delete JSON files on disk
out_dir = _get_output_dir()
for f in files:
try:
base, _ = os.path.splitext(f[0])
json_path = os.path.join(out_dir, base + ".json")
if os.path.exists(json_path):
os.remove(json_path)
except Exception:
pass
try:
group_json_path = os.path.join(out_dir, "_data", f"group_{group_id}.json")
if os.path.exists(group_json_path):
os.remove(group_json_path)
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -372,19 +515,50 @@ def get_group_files(group_id):
_put_db_connection(conn)
def set_group_order(group_id, ordered_filenames):
"""Assign sort_order 0,1,2,... to filenames in the given order."""
conn = get_db_connection()
cur = conn.cursor()
"""Assign sort_order 0,1,2,... to filenames in the given order, resiliently."""
max_retries = 5
for attempt in range(max_retries):
conn = get_db_connection()
cur = conn.cursor()
try:
# Deterministic lock ordering to prevent deadlocks:
# Fetch and lock all unique filenames alphabetically
unique_filenames = sorted(list(set(ordered_filenames)))
if unique_filenames:
cur.execute(
"SELECT filename FROM person WHERE filename = ANY(%s) ORDER BY filename FOR UPDATE",
(unique_filenames,)
)
for idx, fname in enumerate(ordered_filenames):
cur.execute(
"UPDATE person SET sort_order = %s WHERE filename = %s",
(idx, fname)
)
conn.commit()
break
except (psycopg2.OperationalError, psycopg2.extensions.TransactionRollbackError) as e:
pgcode = getattr(e, 'pgcode', None)
if pgcode in ('40P01', '40001') or "deadlock" in str(e).lower():
try:
conn.rollback()
except Exception:
pass
if attempt == max_retries - 1:
raise
sleep_time = (0.1 * (2 ** attempt)) + random.uniform(0.01, 0.1)
time.sleep(sleep_time)
else:
raise
finally:
cur.close()
_put_db_connection(conn)
try:
for idx, fname in enumerate(ordered_filenames):
cur.execute(
"UPDATE person SET sort_order = %s WHERE filename = %s",
(idx, fname)
)
conn.commit()
finally:
cur.close()
_put_db_connection(conn)
sync_group_to_json_async(group_id)
for fname in ordered_filenames:
sync_person_to_json_async(fname)
except Exception:
pass
def get_group_order(group_id):
"""Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST."""
@@ -409,6 +583,13 @@ def set_group_name(group_id, name):
try:
cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id))
conn.commit()
try:
sync_group_to_json_async(group_id)
files = get_group_files(group_id)
for f in files:
sync_person_to_json_async(f[0])
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
@@ -494,3 +675,520 @@ def search_similar_face(embedding, limit=12, exclude_group_id=None):
finally:
cur.close()
_put_db_connection(conn)
def invalidate_all_metadata():
"""Reset metadata columns to NULL so they can be re-analyzed by the backfill worker."""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
UPDATE person
SET people_count = NULL,
anatomical_completeness = NULL,
facial_direction = NULL,
objects = NULL,
pose_description = NULL,
pose_skeleton = NULL
WHERE NOT (filename LIKE '_turntable/%')
""")
conn.commit()
finally:
cur.close()
_put_db_connection(conn)
import os
import math
def _get_output_dir():
# Load config.json to find output_dir
config_paths = ["config.json", "tour-comfy/config.json", "../config.json"]
for p in config_paths:
if os.path.exists(p):
try:
with open(p, "r") as f:
cfg = json.load(f)
if "output_dir" in cfg:
return cfg["output_dir"]
except Exception:
pass
return "../output" # Default fallback
def sync_person_to_json(filename):
"""Write/sync a detailed .json file with the DB fields of the specified filename."""
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT filename, filepath, name, group_id, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing, content_type,
faceswap_source_video, archived, face_embedding, is_source,
pose_description, pose_skeleton, people_count, anatomical_completeness,
facial_direction, objects, description
FROM person
WHERE filename = %s
""", (filename,))
row = cur.fetchone()
if not row:
return
# Parse tags
tags_val = row[4]
tags_list = []
if tags_val:
if isinstance(tags_val, str):
try: tags_list = json.loads(tags_val)
except Exception: tags_list = []
elif isinstance(tags_val, list):
tags_list = tags_val
# Parse objects
obj_val = row[25]
obj_list = []
if obj_val:
if isinstance(obj_val, str):
try: obj_list = json.loads(obj_val)
except Exception: obj_list = []
elif isinstance(obj_val, list):
obj_list = obj_val
# Parse source_refs
ref_val = row[13]
ref_list = []
if ref_val:
if isinstance(ref_val, str):
try: ref_list = json.loads(ref_val)
except Exception: ref_list = []
elif isinstance(ref_val, list):
ref_list = ref_val
# Represent embeddings as lists of floats (for json compatibility)
embedding_list = None
if row[5] is not None:
if isinstance(row[5], str):
embedding_list = [float(x) for x in row[5].strip("[]").split(",") if x.strip()]
else:
embedding_list = list(row[5])
face_embedding_list = None
if row[18] is not None:
if isinstance(row[18], str):
face_embedding_list = [float(x) for x in row[18].strip("[]").split(",") if x.strip()]
else:
face_embedding_list = list(row[18])
# Parse pose_skeleton
pose_skel = None
if row[21]:
try:
pose_skel = json.loads(row[21])
except Exception:
pose_skel = row[21]
data = {
"filename": row[0],
"filepath": row[1],
"name": row[2],
"group_id": row[3],
"tags": tags_list,
"embedding": embedding_list,
"clip_description": row[6],
"prompt": row[7],
"pose": row[8],
"sort_order": row[9],
"group_name": row[10],
"hidden": bool(row[11]) if row[11] is not None else False,
"has_background": bool(row[12]) if row[12] is not None else True,
"source_refs": ref_list,
"has_clothing": row[14],
"content_type": row[15] or "image",
"faceswap_source_video": row[16],
"archived": bool(row[17]) if row[17] is not None else False,
"face_embedding": face_embedding_list,
"is_source": bool(row[19]) if row[19] is not None else False,
"pose_description": row[20],
"pose_skeleton": pose_skel,
"people_count": row[22],
"anatomical_completeness": row[23],
"facial_direction": row[24],
"objects": obj_list,
"description": row[26]
}
# Determine json path
out_dir = _get_output_dir()
if row[1] and os.path.isabs(row[1]):
# Use same directory as absolute filepath
base, _ = os.path.splitext(row[1])
json_path = base + ".json"
else:
# Fallback to output_dir
base, _ = os.path.splitext(row[0])
json_path = os.path.join(out_dir, base + ".json")
os.makedirs(os.path.dirname(json_path), exist_ok=True)
tmp_json_path = json_path + ".tmp"
with open(tmp_json_path, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp_json_path, json_path)
except Exception as e:
print(f"[db] Error syncing person {filename} to JSON: {e}")
finally:
cur.close()
_put_db_connection(conn)
def sync_group_to_json(group_id):
"""Sync all members of a group, calculate group rating and similarity variation,
and save to _data/group_<group_id>.json.
"""
if not group_id:
return
conn = get_db_connection()
cur = conn.cursor()
try:
# Fetch all members
cur.execute("""
SELECT filename, filepath, name, tags, embedding,
clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, content_type, faceswap_source_video,
archived, face_embedding, is_source, pose_description, pose_skeleton
FROM person
WHERE group_id = %s
ORDER BY sort_order ASC, filename ASC
""", (group_id,))
rows = cur.fetchall()
members = []
likes = 0
dislikes = 0
clip_embs = []
face_embs = []
for r in rows:
tags_val = r[3]
tags_list = []
if tags_val:
if isinstance(tags_val, str):
try: tags_list = json.loads(tags_val)
except Exception: tags_list = []
elif isinstance(tags_val, list):
tags_list = tags_val
if "LIKE" in tags_list:
likes += 1
elif "DISLIKE" in tags_list:
dislikes += 1
# Parse embeddings for similarity
if r[4] is not None:
if isinstance(r[4], str):
clip_list = [float(x) for x in r[4].strip("[]").split(",") if x.strip()]
else:
clip_list = list(r[4])
if clip_list:
clip_embs.append(clip_list)
if r[16] is not None:
if isinstance(r[16], str):
face_list = [float(x) for x in r[16].strip("[]").split(",") if x.strip()]
else:
face_list = list(r[16])
if face_list:
face_embs.append(face_list)
members.append({
"filename": r[0],
"filepath": r[1],
"name": r[2],
"tags": tags_list,
"clip_description": r[5],
"prompt": r[6],
"pose": r[7],
"sort_order": r[8],
"hidden": bool(r[10]),
"archived": bool(r[15]),
"is_source": bool(r[17])
})
def cosine_similarity(v1, v2):
if not v1 or not v2: return 0.0
dot = sum(a * b for a, b in zip(v1, v2))
norm1 = math.sqrt(sum(a * a for a in v1))
norm2 = math.sqrt(sum(b * b for b in v2))
if norm1 == 0 or norm2 == 0: return 0.0
return dot / (norm1 * norm2)
def calc_stats(embs):
if len(embs) < 2:
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
similarities = []
for i in range(len(embs)):
for j in range(i + 1, len(embs)):
similarities.append(cosine_similarity(embs[i], embs[j]))
if not similarities:
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
avg_sim = sum(similarities) / len(similarities)
min_sim = min(similarities)
max_sim = max(similarities)
variance = sum((s - avg_sim) ** 2 for s in similarities) / len(similarities)
return {
"average": float(avg_sim),
"min": float(min_sim),
"max": float(max_sim),
"variation": float(math.sqrt(variance)),
"pairs_count": len(similarities)
}
group_name = rows[0][9] if rows else group_id
group_data = {
"group_id": group_id,
"group_name": group_name,
"rating": {
"likes": likes,
"dislikes": dislikes,
"score": likes - dislikes,
"normalized_score": (likes - dislikes) / (likes + dislikes + 2.0) if (likes + dislikes) > 0 else 0.0,
"ratio": likes / (likes + dislikes) if (likes + dislikes) > 0 else 0.0
},
"clip_similarity_stats": calc_stats(clip_embs),
"face_similarity_stats": calc_stats(face_embs),
"members": members
}
out_dir = _get_output_dir()
data_dir = os.path.join(out_dir, "_data")
os.makedirs(data_dir, exist_ok=True)
json_path = os.path.join(data_dir, f"group_{group_id}.json")
tmp_json_path = json_path + ".tmp"
with open(tmp_json_path, "w") as f:
json.dump(group_data, f, indent=2)
os.replace(tmp_json_path, json_path)
except Exception as e:
print(f"[db] Error syncing group {group_id} to JSON: {e}")
finally:
cur.close()
_put_db_connection(conn)
def sync_by_filename(filename):
sync_person_to_json(filename)
person = get_person(filename)
if person and person[1]:
sync_group_to_json(person[1])
from concurrent.futures import ThreadPoolExecutor
_sync_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="db_sync_worker")
def sync_by_filename_async(filename):
_sync_executor.submit(sync_by_filename, filename)
def sync_group_to_json_async(group_id):
_sync_executor.submit(sync_group_to_json, group_id)
def sync_person_to_json_async(filename):
_sync_executor.submit(sync_person_to_json, filename)
def save_db_prompt(prompt_type: str, prompt_text: str, metadata: dict = None):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO prompt (type, prompt_text, metadata, created_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (type, prompt_text) DO UPDATE
SET metadata = EXCLUDED.metadata,
created_at = NOW()
""", (prompt_type, prompt_text, json.dumps(metadata) if metadata is not None else None))
conn.commit()
except Exception as e:
print(f"[db] error saving prompt {prompt_type}: {e}")
try:
conn.rollback()
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
def list_db_prompts(prompt_type: str = None, limit: int = 100):
conn = get_db_connection()
cur = conn.cursor()
try:
if prompt_type:
cur.execute("""
SELECT id, type, prompt_text, metadata, created_at
FROM prompt
WHERE type = %s
ORDER BY created_at DESC
LIMIT %s
""", (prompt_type, limit))
else:
cur.execute("""
SELECT id, type, prompt_text, metadata, created_at
FROM prompt
ORDER BY created_at DESC
LIMIT %s
""", (limit,))
rows = cur.fetchall()
result = []
for r in rows:
meta = r[3]
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
meta = {}
elif meta is None:
meta = {}
result.append({
"id": r[0],
"type": r[1],
"prompt_text": r[2],
"metadata": meta,
"created_at": r[4].isoformat() if r[4] else None
})
return result
except Exception as e:
print(f"[db] error listing prompts: {e}")
return []
finally:
cur.close()
_put_db_connection(conn)
def save_turntable(group_id, state):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
INSERT INTO turntable (group_id, preferred_filename, source_image, seed, n_views, steps, video_path, completed, started_at, completed_at, angles, views)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (group_id) DO UPDATE SET
preferred_filename = EXCLUDED.preferred_filename,
source_image = EXCLUDED.source_image,
seed = EXCLUDED.seed,
n_views = EXCLUDED.n_views,
steps = EXCLUDED.steps,
video_path = EXCLUDED.video_path,
completed = EXCLUDED.completed,
started_at = EXCLUDED.started_at,
completed_at = EXCLUDED.completed_at,
angles = EXCLUDED.angles,
views = EXCLUDED.views
""", (
group_id,
state.get("preferred_filename"),
state.get("source_image"),
state.get("seed"),
state.get("n_views"),
state.get("steps"),
state.get("video_path"),
state.get("completed", False),
state.get("started_at"),
state.get("completed_at"),
json.dumps(state.get("angles", [])),
json.dumps(state.get("views", {}))
))
conn.commit()
except Exception as e:
print(f"[db] error saving turntable for {group_id}: {e}")
try:
conn.rollback()
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
def load_turntable(group_id):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("""
SELECT group_id, preferred_filename, source_image, seed, n_views, steps, video_path, completed, started_at, completed_at, angles, views
FROM turntable
WHERE group_id = %s
""", (group_id,))
row = cur.fetchone()
if not row:
return None
angles_val = []
if row[10]:
try:
angles_val = json.loads(row[10])
except Exception:
angles_val = []
views_val = {}
if row[11]:
try:
views_val = json.loads(row[11])
except Exception:
views_val = {}
return {
"group_id": row[0],
"preferred_filename": row[1],
"source_image": row[2],
"seed": row[3],
"n_views": row[4],
"steps": row[5],
"video_path": row[6],
"completed": bool(row[7]) if row[7] is not None else False,
"started_at": row[8],
"completed_at": row[9],
"angles": angles_val,
"views": views_val
}
except Exception as e:
print(f"[db] error loading turntable for {group_id}: {e}")
return None
finally:
cur.close()
_put_db_connection(conn)
def delete_turntable(group_id):
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute("DELETE FROM turntable WHERE group_id = %s", (group_id,))
conn.commit()
except Exception as e:
print(f"[db] error deleting turntable for {group_id}: {e}")
try:
conn.rollback()
except Exception:
pass
finally:
cur.close()
_put_db_connection(conn)
def migrate_turntable_states_from_disk_to_db():
try:
out_dir = _get_output_dir()
td = os.path.join(out_dir, "_turntable")
if not os.path.isdir(td):
return
gids = [d for d in os.listdir(td) if os.path.isfile(os.path.join(td, d, "state.json"))]
for gid in gids:
p = os.path.join(td, gid, "state.json")
try:
with open(p, "r") as f:
state = json.load(f)
if state:
save_turntable(gid, state)
except Exception as e:
print(f"[db] Error migrating turntable state {gid} from disk: {e}")
except Exception as e:
print(f"[db] Error in migrate_turntable_states_from_disk_to_db: {e}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
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
@@ -29,12 +32,13 @@ def get_model():
def generate_embedding(image_path):
model, preprocess, device = get_model()
try:
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()
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

@@ -17,6 +17,10 @@ Anatomically precise, photo realistic, high detail, keep the characteristics of
Masterpiece, you are in a black void, hyper-realistic, high quality. detailed. detailed skin.
# Outpaint legs
Naturally outpaint draw the legs and feet, and arms of both female keeping the full image intacts.
# Feminine
1 female nude.
@@ -36,7 +40,6 @@ Keep characteristics of person in Image 2
replace the female in Image 1 by the teenage female in Image 2. Keep the Identical person in image 2. photo realistic
# Undress
masterpiece, high quality,
@@ -875,6 +878,7 @@ Anatomically precise, hyperrealistic, high detail, keep the characteristics of t
# Lantern:
you are in a black void.
kneeling, knees apart, sitting back on the heels.
torso upright, spine long.
arms extended overhead, hands clasped or holding an imaginary object.
@@ -886,6 +890,7 @@ Anatomically precise, hyperrealistic, high detail, keep the characteristics of t
# Compass:
you are in a black void.
seated, one leg extended straight forward.
other leg bent, foot placed on the inner thigh of the extended leg.
torso folded forward over the extended leg.
@@ -897,6 +902,7 @@ Anatomically precise, hyperrealistic, high detail, keep the characteristics of t
# Vessel (beta):
you are in a black void.
lying on back, knees bent, feet flat on the ground.
arms extended overhead, hands clasped.
hips lifted slightly, lower back arched.
@@ -908,6 +914,7 @@ Anatomically precise, hyperrealistic, high detail, keep the characteristics of t
# Quiver (beta):
you are in a black void.
lying on stomach, legs together, arms at the sides.
torso lifted in a backbend, chest and shoulders arching upward.
head thrown back, neck stretched.
@@ -919,6 +926,7 @@ Perfect anatomy, hyperrealistic
# Loom:
you are in a black void.
seated, legs extended straight forward, feet flexed.
torso upright, spine long.
arms extended forward, hands reaching toward the feet.
@@ -3042,13 +3050,13 @@ Eyes looking at camera, keeping your facial characteristics as reference photo.
One bar, two bands, one lockplate — access spread open and simultaneously sealed.
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
# Tampon — Internal Plug, External Shield, Integral Collar
# Tampon — Internal Plug, Integral Collar
You are standing in black void.
A single solid steel device, polished and seamless.
An internal plug, contoured and smooth, connected by a short rigid bar to a small external shield covering your vulva.
From the shield, a thin rigid steel bar rises up your abdomen and sternum, terminating in a collar locked around your neck.
One continuous piece — internal plug, external shield, connecting bar, collar.
One continuous piece — internal plug, connecting bar, collar.
Your wrists cuffed to the vertical bar at your hips.
Your ankles cuffed together.
The device occupies and seals simultaneously — internal and external denial in one form.
@@ -4082,11 +4090,21 @@ The body is fully exposed for inspection.
Eyes looking at the camera, facial characteristics as reference.
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
# Labial Lattice 2hand
seated, legs spread wide, one leg extended forward, the other bent at the knee with foot resting flat.
torso leaning slightly back.
arms extended along the thighs, fingers lightly brushing labia.
head tilted back, eyes closed, a subtle smile on the lips.
legs positioned to create a triangular frame around the genitals.
a sense of vulnerability and openness.
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
# Labial Lattice
seated, legs spread wide, one leg extended forward, the other bent at the knee with foot resting flat.
torso leaning slightly back, supported on hands placed beside hips.
arms extended along the thighs, fingers lightly brushing labia.
torso leaning slightly back, supported on one hand placed beside hips.
one arm extended along the thighs, fingers lightly brushing labia.
head tilted back, eyes closed, a subtle smile on the lips.
legs positioned to create a triangular frame around the genitals.
a sense of vulnerability and openness.
@@ -4614,3 +4632,15 @@ of serene anticipation, lips parted ever so slightly. The room around her is a v
In the void, you lay supine with legs spread wide and bent at the knees, your feet flat against the ground. The back is slightly arched, emphasizing the curve of the lower spine. Your arms are outstretched to the sides, parallel to the ground, with elbows slightly bent. The head is tilted back, eyes closed and mouth slightly open, in a state of blissful surrender. The hips are raised just enough to create tension in the lower back, enhancing the silhouette. A steel band encircles each thigh, just above the knee, cinching them tightly together, symbolizing restraint. Your skin glistens with a
sheen of perspiration, accentuating every muscle contour. Anatomically precise, hyperrealistic.
# Integrated Collar Arms Restraint
A subject kneels, your body bent into a powerful, inviting stance.
Your arms are restrained behind your back, firmly secured with an integral collar restraint, a sleek device that encircles your neck, connecting to your wrists with rigid steel bars.
Your hands are clasped together, adding to the restricted pose. Your knees are positioned under your hips, providing stability.
The back is arched slightly upward, head lifted, drawing attention to the exposed shoulders and back.
Your gaze is locked onto the viewer, a look of defiance and anticipation. The pose conveys a mix of sultry energy and
power, the steel bars creating an asymmetrical silhouette. Perfect anatomy, realistic.
# nice pose
standing, feet apart, hands on the hips. torso twisted, one shoulder forward, one back. weight shifted to one leg, opposite hip popped. waist compressed, creating a pronounced hourglass silhouette. head turned to look over the shoulder. Looking back into camera. Pinched, exaggerated. Perfect anatomy, realistic

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())

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()

View File

@@ -94,6 +94,15 @@
<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>
@@ -168,9 +177,12 @@
}
container.innerHTML = files.map(f => `
<div class="item">
<div>
<div class="filename">${f.filename}</div>
<div class="info">Name: ${f.name || 'none'}</div>
<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>
@@ -185,9 +197,12 @@
}
container.innerHTML = files.map(f => `
<div class="item">
<div>
<div class="filename">${f.filename}</div>
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
<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>
@@ -205,9 +220,12 @@
}
container.innerHTML = files.map(f => `
<div class="item">
<div>
<div class="filename">${f.filename}</div>
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
<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>
@@ -222,12 +240,46 @@
}
container.innerHTML = files.map(f => `
<div class="item">
<div class="filename">${f}</div>
<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>

View File

@@ -26,6 +26,14 @@ def state_path(output_dir: str, group_id: str) -> str:
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
@@ -44,6 +52,12 @@ def save_state(output_dir: str, group_id: str, state: dict):
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,
@@ -139,6 +153,12 @@ def delete_state(output_dir: str, group_id: str):
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."""

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()