Compare commits
46 Commits
42f924566e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8acece09 | ||
|
|
ec4fa1aa72 | ||
|
|
94419d7673 | ||
|
|
251d9b1cc8 | ||
|
|
970daeba31 | ||
|
|
145fa686e4 | ||
|
|
66685684c1 | ||
|
|
5cea78c406 | ||
|
|
ad9a2ae078 | ||
|
|
61268de34b | ||
|
|
139785d108 | ||
|
|
150ef6dab0 | ||
|
|
3b9b9e829b | ||
|
|
da1fd4a26c | ||
|
|
684a4805d7 | ||
|
|
30dcb10727 | ||
|
|
de71049079 | ||
|
|
10849ed21a | ||
|
|
3386ec9e84 | ||
|
|
419f732cf6 | ||
|
|
256a3b6ac8 | ||
|
|
d522b2a267 | ||
|
|
6ad11fc6c0 | ||
|
|
3f91694491 | ||
|
|
19e0656ccb | ||
|
|
03b754112c | ||
|
|
bf3773bfd4 | ||
|
|
2ada5fb559 | ||
|
|
52ae51fef3 | ||
|
|
35306327f7 | ||
|
|
08a8cc9b82 | ||
|
|
4e23374093 | ||
|
|
4f388901f3 | ||
|
|
36a244cab4 | ||
|
|
7eb544211e | ||
|
|
78ffb029b9 | ||
|
|
61cd2e559b | ||
|
|
37bf5281c3 | ||
|
|
80b901ac8a | ||
|
|
ce7b0e52e5 | ||
|
|
27e8a09bc1 | ||
|
|
7b12ebd866 | ||
|
|
6d31826c29 | ||
|
|
ee7569f38c | ||
|
|
54d96ef580 | ||
|
|
8df588e594 |
@@ -1,4 +1,6 @@
|
||||
import os
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
import time
|
||||
import json
|
||||
import shutil
|
||||
@@ -58,8 +60,11 @@ def get_processed_files():
|
||||
|
||||
def save_processed_files(processed):
|
||||
try:
|
||||
with open(CONF["processed_file"], 'w') as f:
|
||||
p = CONF["processed_file"]
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, 'w') as f:
|
||||
json.dump(processed, f, indent=2)
|
||||
os.replace(tmp, p)
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving processed file: {e}")
|
||||
|
||||
@@ -162,14 +167,22 @@ def process_image(filename):
|
||||
# Save temporary cropped image for upload
|
||||
cropped_img.save(temp_path, format="PNG")
|
||||
|
||||
prompt = CONF.get("prompt")
|
||||
if not prompt:
|
||||
bp = CONF.get("base_prompts", [])
|
||||
if bp and isinstance(bp, list) and len(bp) > 0:
|
||||
prompt = bp[0]
|
||||
else:
|
||||
prompt = "high quality, masterpiece"
|
||||
|
||||
with open(temp_path, 'rb') as f:
|
||||
files = {'image': (filename, f, 'image/png')}
|
||||
data = {
|
||||
'prompt': CONF["prompt"],
|
||||
'prompt': prompt,
|
||||
'seed': CONF.get("seed", -1),
|
||||
'max_area': CONF.get("max_area", 0)
|
||||
}
|
||||
logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {CONF['prompt']}")
|
||||
logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {prompt}")
|
||||
response = requests.post(CONF["api_url"], files=files, data=data, timeout=600)
|
||||
|
||||
if response.status_code == 200:
|
||||
@@ -181,7 +194,7 @@ def process_image(filename):
|
||||
try:
|
||||
embedding = embeddings.generate_embedding(output_path)
|
||||
gid = filename
|
||||
database.upsert_person(output_filename, filepath=output_path, embedding=embedding, group_id=gid)
|
||||
database.upsert_person(output_filename, filepath=output_path, embedding=embedding, group_id=gid, is_source=True)
|
||||
|
||||
# Also trigger tagging to get auto-name and clip description
|
||||
tag_url = CONF["api_url"].replace("/edit", "/tag")
|
||||
@@ -219,9 +232,14 @@ def update_car_html():
|
||||
return
|
||||
|
||||
try:
|
||||
# Use database to list only non-archived images
|
||||
persons = database.list_persons(include_archived=False)
|
||||
db_images = {p[0] for p in persons}
|
||||
|
||||
# List images in output_dir
|
||||
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
|
||||
images = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions) and f != "car.html"]
|
||||
images = [f for f in os.listdir(output_dir)
|
||||
if f.lower().endswith(extensions) and f != "car.html" and f in db_images]
|
||||
|
||||
# Sort by mtime, newest first
|
||||
images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
|
||||
@@ -238,9 +256,11 @@ def update_car_html():
|
||||
|
||||
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||
|
||||
with open(car_html_path, 'w') as f:
|
||||
tmp_path = car_html_path + ".tmp"
|
||||
with open(tmp_path, 'w') as f:
|
||||
f.write(new_content)
|
||||
logging.info(f"Updated {car_html_path} with {len(images)} images")
|
||||
os.replace(tmp_path, car_html_path)
|
||||
logging.info(f"Updated {car_html_path} atomically with {len(images)} images")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to update car.html: {e}")
|
||||
|
||||
248
AGENTS.md
248
AGENTS.md
@@ -63,30 +63,136 @@ FastAPI :8500 (edit_api.py)
|
||||
- **Systemd**: `comfyui-api`
|
||||
- **DB**: PostgreSQL at `192.168.1.160:5433`, database `dv`, table `person`
|
||||
|
||||
#### Core Endpoints
|
||||
#### In-Memory File Metadata Cache
|
||||
|
||||
To mitigate performance and network latency bottlenecks on remote/shared storage filesystems (e.g. `/mnt/zim`), the API server employs a light, thread-safe in-memory metadata cache (`_file_meta_cache`):
|
||||
- **Cached Items**: File existence (`exists`) and modification timestamps (`mtime`) mapped by `filename`.
|
||||
- **Cache TTL**: 5.0 seconds.
|
||||
- **Idempotency & Invalidation**: Any mutation endpoints (such as crop, padding, duplication, background removal, or deletion) automatically update (`_update_cached_file_meta`) or explicitly delete (`_clear_cached_file_meta`) entries to keep cache and disk perfectly in sync.
|
||||
- **Preloading Optimization**: Leverages cache queries in static indexing loops (`_sync_preloaded_images`, `_write_all_static`, `list_images`) and tracks changes to completely skip redundant static HTML rewrites if the preloaded image set is identical (`_last_preloaded_images_set`).
|
||||
|
||||
#### Endpoints Library
|
||||
|
||||
##### Core Generation & Upload
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/upload` | Upload image; optionally add to existing group (`group_id` + `skip_poses`) |
|
||||
| `POST` | `/batch` | Queue multi-prompt generation job (async) |
|
||||
| `GET` | `/batch/{job_id}` | Poll batch job status / per-item progress |
|
||||
| `DELETE` | `/batch/{job_id}` | Cancel active batch job |
|
||||
| `POST` | `/multi-ref` | Queue a generation job with multiple reference images |
|
||||
| `POST` | `/refine-prompt` | Refine a generation prompt using the external uncensored chat LLM |
|
||||
| `POST` | `/edit` | Edit uploaded image |
|
||||
| `POST` | `/generate-scenery` | Generate scenery background based on video frame + reference image |
|
||||
| `GET` | `/scenery/library` | Return all scenery images grouped by source video reference |
|
||||
|
||||
##### Image Editing & Canvas Expansion (Padding & Crop)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/images/{filename}/crop` | Crop to pixel rect; `as_copy:true` crops a referenced copy instead of modifying in-place |
|
||||
| `POST` | `/images/{filename}/autocrop` | Auto-trim transparent borders in-place |
|
||||
| `POST` | `/images/{filename}/pad` | Expand image canvas with custom padding on each side, optional background fills (`transparent`, `black`, `white`), and optional Qwen-based Outpainting (`outpaint:true`) |
|
||||
| `POST` | `/images/{filename}/rotate` | Rotate clockwise in 90° steps (lossless transpose, in-place) |
|
||||
| `POST` | `/images/{filename}/invert-alpha` | Invert the alpha channel (recovers background removal errors) |
|
||||
| `POST` | `/images/{filename}/duplicate` | Copy image into the same group with timestamp-based filename and `source_refs=[original]` |
|
||||
| `POST` | `/restore-background/{filename}` | Flatten RGBA to opaque RGB via white composite background |
|
||||
|
||||
##### Background Removal
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/remove-background/{filename}` | rembg BG removal, overwrites in-place; sets `has_background=false` + refreshes static data |
|
||||
| `POST` | `/remove-background-sam/{filename}` | SAM2 BG removal → `.nobg.png` sidecar |
|
||||
| `POST` | `/remove-background/group/{group_id}` | Queue background removal for all group members as an async background task |
|
||||
| `GET` | `/sam2/check` | Return whether SAM2 background remover is available |
|
||||
|
||||
##### Metadata, Custom Labels & Group Management
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/images` | List all images with DB metadata |
|
||||
| `GET` | `/images/{filename}` | Serve image file |
|
||||
| `POST` | `/upload` | Upload image; optionally add to existing group (`group_id` + `skip_poses`) |
|
||||
| `POST` | `/batch` | Queue multi-prompt generation job (async) |
|
||||
| `GET` | `/jobs/{id}` | Poll batch job status / per-item progress |
|
||||
| `POST` | `/images/{filename}/set-preferred` | Move to group slot 0; triggers face extraction background task |
|
||||
| `POST` | `/images/{filename}/extract-face` | Extract & save face crop via insightface (background task) |
|
||||
| `POST` | `/images/{filename}/archive` | Soft-archive (hidden from default view) |
|
||||
| `POST` | `/images/{filename}/unarchive` | Restore archived image |
|
||||
| `POST` | `/images/{filename}/set-hidden` | Toggle studio visibility |
|
||||
| `DELETE` | `/images/{filename}` | Delete image + DB record |
|
||||
| `POST` | `/images/{filename}/crop` | Crop to pixel rect (in-place) |
|
||||
| `POST` | `/autocrop/{filename}` | Auto-trim transparent border |
|
||||
| `POST` | `/faceswap` | insightface video faceswap |
|
||||
| `POST` | `/remove-background/{filename}` | rembg BG removal → `.nobg.png` sidecar |
|
||||
| `POST` | `/remove-background-sam/{filename}` | SAM2 BG removal → `.nobg.png` sidecar |
|
||||
| `GET` | `/wireframe/frame/{video_name}` | Extract frame at `?t=` (0–1) from wireframe video |
|
||||
| `GET` | `/groups` | List all groups with members |
|
||||
| `GET` | `/group-names` | Return map of custom group names |
|
||||
| `POST` | `/group-names/{group_id}` | Save custom group name |
|
||||
| `POST` | `/groups/merge` | Merge several groups into one group |
|
||||
| `POST` | `/groups/extract` | Extract a file into its own solo group |
|
||||
| `GET` | `/groups/{group_id}/order` | Return order of members in a group |
|
||||
| `POST` | `/groups/{group_id}/order` | Set custom sort order of group members |
|
||||
| `GET` | `/similar/{filename}` | Find visually similar images using database embeddings |
|
||||
| `GET` | `/names` | Map `filename → person name` |
|
||||
| `POST` | `/names/{filename}` | Map image file to custom character name |
|
||||
| `POST` | `/images/{filename}/tags` | Set image tags list (supports tags like `ARCHIVED`, `HIDDEN`, `SOURCE`, `VISIBLE`, `LIKE`, `DISLIKE`, `21+`) |
|
||||
| `POST` | `/images/{filename}/tag-action` | Add, remove, or toggle a specific tag on an image (automatically handles mutual exclusivity of `LIKE`/`DISLIKE`) |
|
||||
| `POST` | `/images/{filename}/source` | Explicitly toggle marking an image as a source asset (sets/clears `is_source` and toggles the `SOURCE` tag) |
|
||||
| `POST` | `/images/{filename}/estimate-21plus` | Analyze image with the WD tagger to estimate adult/sensitive rating and apply `21+` tag |
|
||||
| `POST` | `/images/bulk-move` | Move a list of filenames to a specific target filmstrip (`VISIBLE`, `HIDDEN`, `ARCHIVED`, `SOURCE`) in bulk, updating tags/columns |
|
||||
| `POST` | `/images/{filename}/undress` | Queue an asynchronous batch job utilizing the `UNDRESS_PROMPT` on the given reference image |
|
||||
|
||||
##### Visibility, Archiving & Deletion
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/images/{filename}/hidden` | Toggle studio visibility (soft-hide) |
|
||||
| `POST` | `/images/{filename}/archive` | Soft-archive image (hidden from default view) |
|
||||
| `POST` | `/images/{filename}/unarchive` | Restore soft-archived image |
|
||||
| `DELETE` | `/images/{filename}` | Delete image file + DB record |
|
||||
| `POST` | `/groups/{group_id}/archive` | Soft-archive an entire group |
|
||||
| `POST` | `/groups/{group_id}/unarchive` | Restore an archived group |
|
||||
| `DELETE` | `/groups/{group_id}` | Delete entire group files and DB records |
|
||||
|
||||
##### 2D Body Pose Tools (rtmlib / RTMPose)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/pose/check` | Report pose-estimator availability + backend (rtmlib or mediapipe) |
|
||||
| `POST` | `/images/{filename}/pose` | Estimate COCO-17 body keypoints (caches descriptor for similar-search) |
|
||||
| `GET` | `/pose/similar/{filename}` | Rank library images by pose similarity to this image |
|
||||
| `POST` | `/pose/similar` | Rank library images by similarity to a supplied (edited) skeleton |
|
||||
| `POST` | `/pose/index` | Build pose descriptors for the whole library (daemon thread) |
|
||||
| `GET` | `/pose/index/status` | Poll pose-index build progress |
|
||||
| `POST` | `/pose/from-wireframe` | Extract frame from a wireframe video and estimate body pose keypoints |
|
||||
| `POST` | `/pose/render` | Render keypoints as OpenPose skeleton image (base64) |
|
||||
| `POST` | `/generate-with-pose` | Generate image from a specific pose skeleton (avoids wireframe bleed-through) |
|
||||
| `GET` | `/poses` | Load pose library from `poses.md` |
|
||||
| `POST` | `/poses` | Create / update / rename a pose (`old_name` to rename) → rewrites `poses.md` |
|
||||
| `DELETE` | `/poses/{name}` | Delete a pose from `poses.md` |
|
||||
|
||||
##### 3D Turntable & Orbit Previews
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/orbit` | Queue an orbit preview generation. Engine `depth` (fast 2.5D card parallax) or `qwen` (turntable) |
|
||||
| `GET` | `/turntable/status` | Return background turntable generation state/progress summary |
|
||||
| `GET` | `/turntable/status/{group_id}` | Return turntable state for a specific group |
|
||||
| `POST` | `/turntable/pause` | Pause background turntable generation |
|
||||
| `POST` | `/turntable/resume` | Resume background turntable generation |
|
||||
| `DELETE` | `/turntable/{group_id}` | Reset turntable cache & records for group to force re-generation |
|
||||
|
||||
##### Face Processing (insightface)
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/faceswap` | insightface video faceswap |
|
||||
| `GET` | `/faceswap/check` | Report available face enhancement backends |
|
||||
| `POST` | `/images/{filename}/extract-face` | Extract & save face crop via insightface (background task) |
|
||||
| `POST` | `/faces/similar` | Find groups with visually similar faces using embeddings |
|
||||
| `POST` | `/faces/index` | Build face embedding index for all preferred faces in library |
|
||||
| `GET` | `/faces/index/status` | Poll face-indexing progress status |
|
||||
| `GET` | `/faces/{group_id}` | Check if face crop exists for group |
|
||||
|
||||
##### Videos & Wireframe Guides
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/videos` | List all available wireframe guide videos |
|
||||
| `GET` | `/wireframe/frame/{video_name}` | Extract frame at `?t=` (0–1) from wireframe video |
|
||||
| `GET` | `/wireframe/duration/{video_name}` | Get video duration (seconds) via ffprobe |
|
||||
| `POST` | `/wireframe/trim` | Trim a wireframe video to given start/end offsets |
|
||||
| `POST` | `/wireframe/frame` | Extract frame at timestamp, returns base64 PNG |
|
||||
| `POST` | `/generate-video` | Stitch list of output images into a short looping MP4 video |
|
||||
|
||||
##### Database, Config & System Diagnostics
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET/POST` | `/config` | Read / write `config.json` |
|
||||
| `GET` | `/db/inconsistencies` | Retrieve consistency report or run a check with `?run_now=true` |
|
||||
| `POST` | `/db/repair` | Run repair actions (restore, import file, assign group, permanently delete) |
|
||||
| `POST` | `/db/cleanup` | Delete DB records of files that are missing on disk |
|
||||
| `GET` | `/health` | API health check status |
|
||||
|
||||
---
|
||||
|
||||
@@ -99,8 +205,14 @@ prompt
|
||||
│ YES → strip keywords from Qwen prompt (keeps `is_transparent` flag)
|
||||
│ set negative: "deformed anatomy, watermark, logo"
|
||||
│
|
||||
├─ Canvas Expansion / Outpainting requested?
|
||||
│ YES → if outpaint=True:
|
||||
│ ├─ If source is transparent (RGBA): composite onto flat black background for maximum contrast.
|
||||
│ ├─ Formulate dynamic prompt description: e.g. "replacing the black/white background areas"
|
||||
│ └─ Append outpainting prompt instructions: "Naturally outpaint and extend..."
|
||||
│
|
||||
├─ Upload reference image(s) to ComfyUI /upload/image
|
||||
│ image1 = source image
|
||||
│ image1 = source image (composited RGB if outpainting transparent images)
|
||||
│ image2 = wireframe pose guide frame (optional)
|
||||
│
|
||||
├─ Patch workflow graph nodes (prompt, size, seed, sampler)
|
||||
@@ -217,6 +329,11 @@ Model: `buffalo_l`
|
||||
| source_refs | TEXT (JSON) | Original filenames this was derived from |
|
||||
| content_type | TEXT | `image` or `video` |
|
||||
| faceswap_source_video | TEXT | Source video for faceswapped clips |
|
||||
| face_embedding | `vector(512)` | Face recognition embedding for visually matching character faces |
|
||||
| is_source | `BOOLEAN` | Whether the file is a primary reference/source asset |
|
||||
| tags | TEXT (JSON) | Custom user-applied tags/labels (e.g., `['VISIBLE', 'LIKE', '21+']`) |
|
||||
| pose_description | TEXT | AI-generated descriptive text of the body pose |
|
||||
| pose_skeleton | TEXT | Coordinates and scoring details of the 2D COCO-17 body pose skeleton |
|
||||
|
||||
---
|
||||
|
||||
@@ -227,21 +344,78 @@ Single-page application (~5000 lines). No build step — pure HTML/CSS/JS.
|
||||
**Modes**:
|
||||
- **Gallery** — grid of all groups; click to open Studio
|
||||
- **Studio** — filmstrip + large viewer + sidebar tabs
|
||||
- **Database Consistency** (`trash.html` SPA) — dedicated panel for identifying legacy/orphaned records, untracked files, and manual restore/delete of archived assets
|
||||
|
||||
**Sidebar tabs**:
|
||||
|
||||
| Tab | Purpose |
|
||||
|-----|---------|
|
||||
| Generate | Camera angle picker, pose selector, wireframe guide, prompt override, fine-tune textarea, batch submit |
|
||||
| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete actions, manual crop |
|
||||
| Generate | Camera angle picker, pose selector (with inline edit/add/delete → `poses.md`), wireframe guide, auto-growing prompt override with history autocomplete, fine-tune textareas, multi-ref thumbnails (click to deselect), batch submit |
|
||||
| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete, manual crop/expand padding (copy or in-place), rotate ±90°, No-BG / Invert-α, duplicate, Pose preview (draggable + similar-pose search) |
|
||||
| Faceswap | Source face selector, target video, preview quality toggle |
|
||||
| Segment | SAM2 and rembg BG removal buttons |
|
||||
| Scenery | Scenery video reference for generation |
|
||||
|
||||
### Key Studio Features
|
||||
|
||||
#### Canvas Expansion & Manual Padding (Interactive Tooling)
|
||||
The **Info** sidebar and dedicated toolbar provide professional, reactive manual padding controls:
|
||||
- **Percent/Pixel Harmony**: Seamlessly accepts both absolute integer pixel values (e.g., `100px`) and percentage-based inputs (e.g., `10%` or `0.1`), ensuring perfect formatting and suffix preservation.
|
||||
- **Quick Adjustment Presets**: Rapidly adjust margins with single-click actions for `+10%`, `+20%`, `+50%`, `+100px`, or `Reset` to clear all.
|
||||
- **Uniform Locking**: Enabling the **Uniform** option synchronizes changes on any single padding field to all four sides simultaneously.
|
||||
- **Intelligent Previews**: Dynamically renders real-time color-coded canvas border previews based on the selected fill type (Amber dashed for `transparent`, translucent black for `black`, and translucent white for `white`).
|
||||
- **Shortcuts**: Confirm cropping or padding instantly with `Enter`, or dismiss/cancel the operation using `Escape`.
|
||||
|
||||
#### Reactive Layout Optimization
|
||||
To prevent desyncs during image transitions, crops, canvas expansion, or 2.2x zooming:
|
||||
- A `ResizeObserver` is registered on the primary studio viewer image (`lbImg`).
|
||||
- Automatically triggers a layout and geometry recalculation, cleanly and immediately adjusting the background transparency checkerboard grid and padding visual overlays to match the actual visual boundaries of the contained image.
|
||||
|
||||
#### Multiple Filmstrips System
|
||||
The filmstrip bar has been updated with five tabs that filter the group's images dynamically:
|
||||
- **Group Active**: The default filmstrip containing the main/preferred assets of the group.
|
||||
- **Group Variant** (`VISIBLE`): Shows all standard visible variants within the group.
|
||||
- **Hidden** (`HIDDEN`): Soft-hidden variants (excluded from main cycling).
|
||||
- **Source** (`SOURCE`): Displays reference images explicitly marked as source assets.
|
||||
- **Archived** (`ARCHIVED`): Displays soft-archived variants.
|
||||
- **Drag-and-Drop Reclassification**: Users can drag any variant thumbnail from the filmstrip and drop it directly onto any of the tab buttons to instantly update its tag/status and reassign it to that list.
|
||||
|
||||
#### Bulk Multi-Select & Actions
|
||||
Toggling the **Multi-select** button unlocks checkbox-based selection across the filmstrip:
|
||||
- Provides a live selection counter.
|
||||
- Enables bulk move/reclassification buttons to dispatch batch updates to `/images/bulk-move` (e.g., `Move to Group`, `Move to Hidden`, `Move to Source`, `Move to Archived`).
|
||||
|
||||
#### Seamless Non-Blinking State Updates
|
||||
To prevent irritating browser reload blinks or blank screen latency during intensive image editing operations:
|
||||
- **Local Metadata Cloning**: Duplication, manual cropping, padding, and background removal immediately clone the original's DB metadata values locally on the client and splice the new file record into the active filmstrip list.
|
||||
- **Optimistic Merge**: During periodic background server syncs (`refreshNow`), an optimistic merge algorithm compares the local client-side list and the server JSON. It preserves any freshly created local assets that haven't yet been processed/synced on the backend, maintaining perfect continuity.
|
||||
|
||||
#### Studio Keyboard Shortcuts Reference
|
||||
|
||||
| Key | Action | Description |
|
||||
|-----|--------|-------------|
|
||||
| `P` | Toggle Privacy Mode | Replaces the page with a discrete "AI Assist" study-chat and disguises taskbar thumbnails. |
|
||||
| `C` | Toggle Checkerboard | Toggle transparent background checkerboard grid. |
|
||||
| `A` | Expand/Pad Canvas | Instantly trigger the manual padding toolbar. |
|
||||
| `G` / `Alt+Enter` | Trigger Generation | Clicks the main "Generate" button (when Generate tab is active). |
|
||||
| `ArrowLeft` / `ArrowRight` | Nav Filmstrip | Cycle through the current list of image variants (debounced). |
|
||||
| `Home` / `End` | Jump to Bounds | Instantly jump to the first or last variant in the filmstrip. |
|
||||
| `Space` | Play/Pause Video | Pauses or plays a running wireframe/clip inside the viewer. |
|
||||
| `H` | Toggle Hidden | Reclasses the image between standard variants and hidden list. |
|
||||
| `F` | Set as Preferred | Reorders the group, placing the current image at index 0 (as preferred thumbnail). |
|
||||
| `S` | Toggle Source | Mark/unmark current image as a primary reference/source asset. |
|
||||
| `E` | Run 21+ Tagger | Analyze image via WD tagger and apply/remove the `21+` tag. |
|
||||
| `Delete` | Soft-Archive | Move the current image into the Archive filmstrip. |
|
||||
| `ArrowUp` / `ArrowDown` | Shift Order | Move/reorder the current image earlier or later in the group (debounced). |
|
||||
| `2` | Move to Second | Instantly reorder current image to the 2nd position in the group. |
|
||||
| `9` | Move to Last-1 | Instantly reorder current image to the second to last position. |
|
||||
| `Escape` | Cancel / Dismiss | Cancel cropping, padding, close variant picker, or exit selection mode. |
|
||||
|
||||
**Key state variables**:
|
||||
- `lbCurrentGid` — active group ID
|
||||
- `lbIdx` — current filmstrip position
|
||||
- `_followLatestGid` — auto-follow newly generated image
|
||||
- `_followLatestFilename` — auto-follow specific new output file
|
||||
- `_sbSelectedAngles` — selected camera angle names
|
||||
- `_sbWireframeRef` / `_sbWireframeTime` — wireframe video + frame time
|
||||
- `privacyMode` — privacy overlay toggle (key: `P`)
|
||||
@@ -258,20 +432,50 @@ The studio Generate tab shows a video picker + time scrubber. On generation:
|
||||
|
||||
---
|
||||
|
||||
## Pose Tools (2D body pose)
|
||||
|
||||
A posenet-style toolset built on a feature-detected estimator. Preferred backend is
|
||||
**rtmlib** (RTMPose, ONNX via the already-installed `onnxruntime`); **mediapipe** is a
|
||||
fallback. If neither is installed the endpoints return `501` and the UI shows an install hint.
|
||||
|
||||
- **Install (A6000 venv):** `~/comfyui/venv/bin/pip install rtmlib`. Models (~150 MB) auto-
|
||||
download to `~/.cache/rtmlib` on first use.
|
||||
- **CUDA libs:** `onnxruntime-gpu` needs the venv's `nvidia-*-cu12` libs on the loader path.
|
||||
`a6000-comfy/start_api.sh` adds them to `LD_LIBRARY_PATH` at launch (torch finds them via
|
||||
RPATH, onnxruntime does not).
|
||||
|
||||
**Output format:** COCO-17 keypoints (`POSE_KEYPOINT_NAMES`) + `POSE_SKELETON` edge list,
|
||||
returned in image pixels with per-joint score.
|
||||
|
||||
**Studio overlay (`car.html`):** the **Pose** button overlays the skeleton on the viewer
|
||||
(letterbox-aware canvas). Joints are **draggable** to refine/explore a pose. Overlay toolbar:
|
||||
*Reset* (re-detect) and *Similar pose* (rank the library by the current — possibly edited —
|
||||
skeleton). Results render as a thumbnail strip; clicking one opens that image's group.
|
||||
|
||||
**Similarity model (`edit_api.py`):**
|
||||
- `_pose_descriptor(keypoints)` → translation/scale-invariant vector: centered on hip midpoint
|
||||
(fallback shoulders), scaled by torso length, 17×(x,y) + visibility mask. `None` if < 6 joints.
|
||||
- `_pose_distance(a, b)` → weighted L2 over jointly-visible joints, taking the min of the direct
|
||||
and the **left-right-mirrored** comparison so mirrored poses match.
|
||||
- **Pose index:** descriptors are cached in `<output>/_data/poses_index.json`. It fills
|
||||
incrementally whenever `/images/{f}/pose` runs, or in bulk via `/pose/index` (daemon thread,
|
||||
batched writes every 50, progress via `/pose/index/status`).
|
||||
|
||||
## File Layout
|
||||
|
||||
```
|
||||
qwen-image-edit-rapid-aio-nsfw-v23/
|
||||
├── tour-comfy/
|
||||
│ ├── car.html # frontend SPA
|
||||
│ ├── car.html # frontend SPA (Studio Interface)
|
||||
│ ├── trash.html # frontend Database Consistency Panel SPA
|
||||
│ ├── edit_api.py # FastAPI backend
|
||||
│ ├── database.py # PostgreSQL helpers
|
||||
│ ├── watcher.py # output dir auto-processor
|
||||
│ ├── config.json # output_dir, comfy_url, etc.
|
||||
│ └── workflow_qwen_edit.json # ComfyUI graph template
|
||||
├── a6000-comfy/ # ComfyUI install (symlinked to ~/comfyui)
|
||||
├── AGENTS.md # this file
|
||||
├── architecture2.svg # system architecture diagram
|
||||
├── AGENTS.md # this file (System Documentation)
|
||||
├── ARCHETECTURE.svg # system architecture diagram
|
||||
├── backlog.md # feature backlog
|
||||
└── requirements.txt # Python deps
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
148
Add completion API for IDE LLM integration.md
Normal file
148
Add completion API for IDE LLM integration.md
Normal file
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
|
||||
@@ -6,11 +6,24 @@ source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
|
||||
source "$VENV/bin/activate"
|
||||
cd "$API_DIR"
|
||||
|
||||
# onnxruntime-gpu (used by the body-pose estimator) needs the CUDA runtime libs that
|
||||
# ship inside the venv's nvidia-*-cu12 pip packages on the loader path. torch finds
|
||||
# them via RPATH, but onnxruntime does not — so put them on LD_LIBRARY_PATH here.
|
||||
NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -type d 2>/dev/null | paste -sd:)
|
||||
[ -n "$NV_LIBS" ] && export LD_LIBRARY_PATH="${NV_LIBS}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
export COMFY_URL="http://127.0.0.1:8188"
|
||||
export HOST="0.0.0.0"
|
||||
export PORT="8500"
|
||||
|
||||
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
|
||||
# This comfortably allows full-HD-ish outputs like 1920x1080.
|
||||
# Override via MAX_AREA when needed.
|
||||
export MAX_AREA="${MAX_AREA:-2097152}"
|
||||
|
||||
# @LEGACY PREVIOUS VERSION
|
||||
# The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP
|
||||
# output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA.
|
||||
export MAX_AREA="${MAX_AREA:-1048576}"
|
||||
# export MAX_AREA="${MAX_AREA:-1048576}"
|
||||
|
||||
exec python edit_api.py
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="900">
|
||||
<style>
|
||||
.component {
|
||||
fill: #e3f2fd;
|
||||
stroke: #1976d2;
|
||||
stroke-width: 2;
|
||||
rx: 15;
|
||||
ry: 15;
|
||||
filter: url(#shadow);
|
||||
}
|
||||
.label {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-anchor: middle;
|
||||
fill: #1a237e;
|
||||
}
|
||||
.sublabel {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
text-anchor: middle;
|
||||
fill: #333;
|
||||
}
|
||||
.connection {
|
||||
stroke: #757575;
|
||||
stroke-width: 2;
|
||||
marker-end: url(#arrowhead);
|
||||
}
|
||||
.database {
|
||||
fill: #ffebee;
|
||||
stroke: #d32f2f;
|
||||
stroke-width: 2;
|
||||
rx: 15;
|
||||
ry: 15;
|
||||
filter: url(#shadow);
|
||||
}
|
||||
.service {
|
||||
fill: #e8f5e9;
|
||||
stroke: #388e3c;
|
||||
stroke-width: 2;
|
||||
rx: 15;
|
||||
ry: 15;
|
||||
filter: url(#shadow);
|
||||
}
|
||||
.system {
|
||||
fill: #fff3e0;
|
||||
stroke: #ef6c00;
|
||||
stroke-width: 2;
|
||||
rx: 15;
|
||||
ry: 15;
|
||||
filter: url(#shadow);
|
||||
}
|
||||
.title {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
text-anchor: middle;
|
||||
fill: #1a237e;
|
||||
}
|
||||
.subtitle {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
text-anchor: middle;
|
||||
fill: #555;
|
||||
}
|
||||
.group-title {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-anchor: start;
|
||||
fill: #1a237e;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Drop shadow filter -->
|
||||
<defs>
|
||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="3" dy="3" stdDeviation="4" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
|
||||
<!-- Arrowhead marker definition -->
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7"
|
||||
refX="0" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#757575"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Main system container -->
|
||||
<rect x="50" y="50" width="1300" height="800" class="system"/>
|
||||
<text x="700" y="40" class="title">Qwen Image Edit Rapid-AIO v23 System Architecture</text>
|
||||
<text x="700" y="65" class="subtitle">Complete System Overview with All Components and Interactions</text>
|
||||
|
||||
<!-- Group Title: Input/Processing Pipeline -->
|
||||
<text x="100" y="100" class="group-title">Input/Processing Pipeline</text>
|
||||
|
||||
<!-- Stage Directory -->
|
||||
<rect x="100" y="120" width="200" height="100" class="component"/>
|
||||
<text x="200" y="140" class="label">Stage Directory</text>
|
||||
<text x="200" y="160" class="sublabel">/mnt/zim/tour-comfy/stage</text>
|
||||
<text x="200" y="180" class="sublabel">Input images</text>
|
||||
|
||||
<!-- Output Directory -->
|
||||
<rect x="100" y="250" width="200" height="100" class="component"/>
|
||||
<text x="200" y="270" class="label">Output Directory</text>
|
||||
<text x="200" y="290" class="sublabel">/mnt/zim/tour-comfy/output</text>
|
||||
<text x="200" y="310" class="sublabel">Processed images</text>
|
||||
|
||||
<!-- Failed Directory -->
|
||||
<rect x="100" y="380" width="200" height="100" class="component"/>
|
||||
<text x="200" y="400" class="label">Failed Directory</text>
|
||||
<text x="200" y="420" class="sublabel">/mnt/zim/tour-comfy/failed</text>
|
||||
<text x="200" y="440" class="sublabel">Failed images</text>
|
||||
|
||||
<!-- Group Title: System Services -->
|
||||
<text x="100" y="520" class="group-title">System Services</text>
|
||||
|
||||
<!-- System Target -->
|
||||
<rect x="100" y="540" width="200" height="100" class="component"/>
|
||||
<text x="200" y="560" class="label">System Target</text>
|
||||
<text x="200" y="580" class="sublabel">comfyui.target</text>
|
||||
<text x="200" y="600" class="sublabel">Service orchestration</text>
|
||||
|
||||
<!-- Group Title: Backend Components -->
|
||||
<text x="500" y="100" class="group-title">Backend Components</text>
|
||||
|
||||
<!-- ComfyUI Backend Component -->
|
||||
<rect x="500" y="120" width="250" height="120" class="component"/>
|
||||
<text x="625" y="140" class="label">ComfyUI Backend</text>
|
||||
<text x="625" y="160" class="sublabel">Qwen-Rapid-NSFW-v23</text>
|
||||
<text x="625" y="180" class="sublabel">GGUF Model (21.8GB)</text>
|
||||
<text x="625" y="200" class="sublabel">ComfyUI Workflow Engine</text>
|
||||
|
||||
<!-- GFPGAN Component -->
|
||||
<rect x="500" y="270" width="250" height="100" class="component"/>
|
||||
<text x="625" y="290" class="label">GFPGAN Face Restoration</text>
|
||||
<text x="625" y="310" class="sublabel">gfpgan directory</text>
|
||||
<text x="625" y="330" class="sublabel">Face swap functionality</text>
|
||||
|
||||
<!-- Prompt Management -->
|
||||
<rect x="500" y="400" width="250" height="100" class="component"/>
|
||||
<text x="625" y="420" class="label">Prompt Management</text>
|
||||
<text x="625" y="440" class="sublabel">prompt_pose_book.md</text>
|
||||
<text x="625" y="460" class="sublabel">Base prompts & pose book</text>
|
||||
|
||||
<!-- Configuration Files -->
|
||||
<rect x="500" y="530" width="250" height="100" class="component"/>
|
||||
<text x="625" y="550" class="label">Configuration</text>
|
||||
<text x="625" y="570" class="sublabel">config.json</text>
|
||||
<text x="625" y="590" class="sublabel">API Settings & Paths</text>
|
||||
|
||||
<!-- Group Title: API and Database -->
|
||||
<text x="1000" y="100" class="group-title">API and Database</text>
|
||||
|
||||
<!-- FastAPI Service Component -->
|
||||
<rect x="1000" y="120" width="250" height="100" class="service"/>
|
||||
<text x="1125" y="140" class="label">FastAPI Service</text>
|
||||
<text x="1125" y="160" class="sublabel">edit_api.py</text>
|
||||
<text x="1125" y="180" class="sublabel">Image Processing API</text>
|
||||
|
||||
<!-- PostgreSQL Database -->
|
||||
<rect x="1000" y="250" width="250" height="100" class="database"/>
|
||||
<text x="1125" y="270" class="label">PostgreSQL Database</text>
|
||||
<text x="1125" y="290" class="sublabel">host: 192.168.1.160</text>
|
||||
<text x="1125" y="310" class="sublabel">port: 5433, dbname: dv</text>
|
||||
|
||||
<!-- Group Title: Watcher Service -->
|
||||
<text x="1000" y="400" class="group-title">Watcher Service</text>
|
||||
|
||||
<!-- Folder Watcher Component -->
|
||||
<rect x="1000" y="420" width="250" height="100" class="service"/>
|
||||
<text x="1125" y="440" class="label">Folder Watcher</text>
|
||||
<text x="1125" y="460" class="sublabel">watcher.py</text>
|
||||
<text x="1125" y="480" class="sublabel">car.html Generator</text>
|
||||
|
||||
<!-- Group Title: System Integration -->
|
||||
<text x="1000" y="550" class="group-title">System Integration</text>
|
||||
|
||||
<!-- System Services -->
|
||||
<rect x="1000" y="570" width="250" height="100" class="component"/>
|
||||
<text x="1125" y="590" class="label">System Services</text>
|
||||
<text x="1125" y="610" class="sublabel">comfyui-backend.service</text>
|
||||
<text x="1125" y="630" class="sublabel">comfyui-api.service</text>
|
||||
<text x="1125" y="650" class="sublabel">comfyui-watcher.service</text>
|
||||
|
||||
<!-- Connections between components -->
|
||||
|
||||
<!-- Stage to ComfyUI Backend -->
|
||||
<line x1="300" y1="170" x2="500" y2="170" class="connection"/>
|
||||
<text x="400" y="160" class="sublabel">Image Upload</text>
|
||||
|
||||
<!-- ComfyUI Backend to Output Directory -->
|
||||
<line x1="750" y1="180" x2="950" y2="180" class="connection"/>
|
||||
<text x="850" y="170" class="sublabel">Processing Result</text>
|
||||
|
||||
<!-- ComfyUI Backend to Database -->
|
||||
<line x1="750" y1="300" x2="1000" y2="300" class="connection"/>
|
||||
<text x="875" y="290" class="sublabel">Database Access</text>
|
||||
|
||||
<!-- FastAPI to Database -->
|
||||
<line x1="1250" y1="170" x2="1250" y2="300" class="connection"/>
|
||||
<text x="1260" y="235" class="sublabel">Database Access</text>
|
||||
|
||||
<!-- Folder Watcher to Output Directory -->
|
||||
<line x1="1250" y1="470" x2="950" y2="470" class="connection"/>
|
||||
<text x="1100" y="460" class="sublabel">car.html Generation</text>
|
||||
|
||||
<!-- Folder Watcher to Database -->
|
||||
<line x1="1250" y1="520" x2="1250" y2="350" class="connection"/>
|
||||
<text x="1260" y="435" class="sublabel">Database Updates</text>
|
||||
|
||||
<!-- System Target to Services -->
|
||||
<line x1="300" y1="590" x2="1000" y2="590" class="connection"/>
|
||||
<text x="650" y="580" class="sublabel">Service Orchestration</text>
|
||||
|
||||
<!-- ComfyUI Backend to Prompt Management -->
|
||||
<line x1="750" y1="240" x2="750" y2="400" class="connection"/>
|
||||
<text x="760" y="320" class="sublabel">Prompt Usage</text>
|
||||
|
||||
<!-- ComfyUI Backend to GFPGAN -->
|
||||
<line x1="750" y1="390" x2="750" y2="270" class="connection"/>
|
||||
<text x="760" y="330" class="sublabel">Face Restoration</text>
|
||||
|
||||
<!-- System Target to ComfyUI Backend -->
|
||||
<line x1="300" y1="590" x2="500" y2="180" class="connection"/>
|
||||
<text x="400" y="500" class="sublabel">Start Backend</text>
|
||||
|
||||
<!-- System Target to FastAPI -->
|
||||
<line x1="300" y1="590" x2="1000" y2="170" class="connection"/>
|
||||
<text x="650" y="500" class="sublabel">Start API</text>
|
||||
|
||||
<!-- System Target to Watcher -->
|
||||
<line x1="300" y1="590" x2="1000" y2="470" class="connection"/>
|
||||
<text x="650" y="500" class="sublabel">Start Watcher</text>
|
||||
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.6 KiB |
176
backlog.md
176
backlog.md
@@ -1,3 +1,123 @@
|
||||
# Backlog
|
||||
|
||||
## Shipped (recent)
|
||||
|
||||
Studio / Generate UX:
|
||||
|
||||
- ✅ Crop reaches the bottom edge (toolbar moved to top); choose copy-vs-replace ("Save as copy")
|
||||
- ✅ Paste-to-group image shows immediately (no refresh needed)
|
||||
- ✅ Generated images store the prompt used (and pose name) in the DB
|
||||
- ✅ Edit / add / delete poses from the Generate tab (persists to `poses.md`)
|
||||
- ✅ flag beta poses in ui — beta badge + editable beta flag
|
||||
- ✅ Custom prompt field auto-grows; keeps `list="promptHistory"` autocomplete
|
||||
- ✅ Remove-BG persists `has_background` + refreshes static data (survives reload)
|
||||
- ✅ Invert-α button (fixes wrong-segment background removal)
|
||||
- ✅ zien van de geselecteerde images in de prompt — multi-ref thumbs visible; click to deselect
|
||||
- ✅ Landing-page group merge syncs immediately (synchronous static write)
|
||||
- ✅ Rotate ±90° in the studio
|
||||
|
||||
Pose tools (rtmlib / RTMPose, 2D):
|
||||
|
||||
- ✅ Pose skeleton preview overlay in studio
|
||||
- ✅ Draggable joints to refine/explore a pose
|
||||
- ✅ Find similar poses across the library (translation/scale/mirror-invariant; `poses_index.json`)
|
||||
- ✅ Gesture presets in pose toolbar (T-pose, wave, arms raised, hands on hips, turn ±45°)
|
||||
- ✅ "Generate ▸" in pose toolbar — renders skeleton as OpenPose image → Qwen image2 guide → new generation
|
||||
- ✅ "Orbit all" button in Generate tab selects all 6 absolute camera angles at once
|
||||
|
||||
Scenery:
|
||||
|
||||
- ✅ source_refs now stores all references: person image + video name + extra ref image
|
||||
- ✅ /scenery/library endpoint — all scenery grouped by source video
|
||||
- ✅ "Previous scenery" panel in Scenery tab — browse & reuse scenery from any group, grouped by video
|
||||
|
||||
## Open
|
||||
|
||||
- remove "clean-db" button from UI- studio. But perhaps check daily for inconsistencies and show identified issues in a "Archive/Trash" view perhaps seperate html?
|
||||
- ✅ OUTPAINT feature/ crop/ pad call qwen
|
||||
- the pad/crop i think should allow also percentage values, since we looking in scaled images.
|
||||
- valid multi-input way to facilite the 2.5/3d orbit
|
||||
- ✅ Refine a POSE, so we have the ref (source) image + pose prompt. use the tour GPU + api to refine the pose with user instruction. Then use the source image + updated prompt to generate a new image.
|
||||
- introduce in the custom padding features + auto crop afterwards
|
||||
- ✅ in car.html - studio view we have a action to pad the image we also have this feature in the "Generate" tab.
|
||||
- ✅ add a checkbox (or similar) to instruct the image-model to outpaint the padded area. Also implement the outpainting functionality in edit_api.py, we can instruct qwen-image. We can have the same functionality in the "Generate" tab when no prompt is given but a padding is given.
|
||||
- ✅ we have a bug that when pressing the ui-action "duplicate" and "pad" the browser needs to refresh before we see the result. Other actions like generating from pose do not hae this bug, fix the bug and boy scout adjecnt actions if they are missing the required technical follow up. Fix the bug for the features.
|
||||
- ✅ In the "Generate" tab, the CUSTOM prompt is shown twice, above POSES and statically always in view in the right bottom. Remove the statically CUSTOM prompt from the UI.
|
||||
- The Generate button is always visible in the "Generate" tab, however we cannot jump to it via keyboard. Add a keyboard shortcut to jump to the Generate button "G" or simialler.
|
||||
- ✅ remove the "set prompt"
|
||||
- after pose generation, sometimes small black area's occur in the subject. ( presumably the _apply_transparency_black_bg addition)
|
||||
- ✅ studio view - orbit tab - view sometimes seems accelerated, liek the tiems is set twice. And most of all a step in the turn seems in in the wrong direction. maybe its an image-ordering bug, but i think its because the image-model generated the step 240deg into the wrong direction
|
||||
- Improve the UI/UX for the end-user experience. Where effort is low and impact is high.
|
||||
- add an action + key-bind to move a the current selected image in the filmstrip to the end of the filmstrip. ( so also do the actual reordering of the image )
|
||||
-
|
||||
- animate limbs along keyframe curves (true motion)
|
||||
- 3D-aware rotation / "circling around" with depth estimation
|
||||
- camera-orbit batch with consistent lighting across views
|
||||
- ✅ extract a frame from a clip (scenery)
|
||||
- faceswap defaults nothing enabled, see jobs even when refresh
|
||||
- rating based pose, thumbs up/down find good/bad poses easier, (pairs well with the new pose index — could weight similar-pose results by rating)
|
||||
- ✅ after orbit generation, the images cannot be duplicated, set preferred, archived or otherwise modified server response "{"detail":"Not Found"}"
|
||||
- after orbit generation, dont generate the mp4, but do show the orbit orbit-full-img-wrap and render the alpha images 1fps ourselves.
|
||||
- allow re-ordering of the images in the orbit generated frames
|
||||
- ✅sometimes the UI renders two orbits in the same orbitFullViewer, must be a timer not being cleared in the ui
|
||||
- The UI wants to car.html:2817 GET file:///mnt/zim/tour-comfy/output/_turntable/up_34e48ffb/turntable.jpg?t=1782562827857 net::ERR_FILE_NOT_FOUND, often want to show the wireframe - pose
|
||||
- ✅ Fixed: statically serve turntable and orbit preview files from filesystem, not via server (avoid GET /output/... 304 Not Modified log spam)
|
||||
- Check face similarity in a group of images.
|
||||
- ✅ Fixed: implement a tag-system for images, will be used to filter, group, sort and search images. Obvious tags are VISIBLE, ARCHIVED, LIKE, DISLIKE, RATED, GROUP_ANCHOR, GROUP_MEMBER, BACKGROUND etc..
|
||||
- ✅ Fixed: mark image as source (with dedicated 'S' keybind)
|
||||
- ✅ Fixed: introduce multiple filmstrips (Group, Hidden, Source, Archived with drag-and-drop & bulk selection actions).
|
||||
- samengestelde outpaint featurue lijkt niet goed te werken, but individual steps work perfect.
|
||||
- regenerate a single orbit - pose
|
||||
- when generating a known pose, we should know roughly the image ratio. Make a pose-table, when a pose is generated store the wireframe and meta data
|
||||
- we can rate an image if its OVER21, manually now in the UI. this is fine, however we want to backfill this TAG for evey image
|
||||
- backfill, people-count, 21+, IS_COMPLETE, RESTRAINT
|
||||
- ✅ the wireframe / videos / sub-clips worden niet direct weergegeven
|
||||
- make the faceswap preview mode fixed size instead of 50% we dont know how big the original clip is. just 470p or something.
|
||||
- include 2 filter(s) in the filmstrip for VIDEO and ORBIT only show them if the sub-set is not empty.
|
||||
- add a 2nd visible filmstrip to easy move items from one to another strip in the UI
|
||||
- on the landing page we now just play all images of an group. However if the orbit generaition is complete. I want to circulate those images , (this basically is a new filmstrip feature)) so we set the orbit in a filmstrip by default, but can modify it. Also we want the face to be visible in the landing page and the most prominent (most 3d alike full body with femine descriptors)) image
|
||||
- still looking for that killer-feminine shot, one image that would show most characteristics of a nude female including eyes.
|
||||
- scrolling past the Scne - STANDALONE / UPLOADED IMAGES still is unpleasant. I think all images get completly resized and 100x recalculated when moving the scrollbar there.
|
||||
- introduce bulk select in trash.html
|
||||
- tab "SAM" checkerboard icon to studio-view overlay
|
||||
- in the landing page hide SOURCE images
|
||||
- als het input bestand real is, dan gaat echt elke opvolging perfect. supe plaatjes.. (3 kwart maakt de opvolgende plaatjes ook altijd engaging.. top down (vanwege ruimte))
|
||||
- When open the app via the launcher, make it privacy enabled by default, so the privacy screen will appear.
|
||||
- When open a group by default hide orbit images, videos
|
||||
- Do not show videos in the landingpage image-loop
|
||||
- Make - html per group and html for the landing page. Decouple the landingpage from the studio and studio is hydrated available per group
|
||||
- update svg
|
||||
- Extract the heavy model API, so we can unit-test it. ( and we can stand-alone disable it ) and doesnt require the entire 41gb model to load into memory for a simple backend update
|
||||
- Save pose-prompt history + refine + reverse engineer + Scenery prompt in database. create a new db table called prompt, add the type of prompt for exmaple refine or scene to the record and all relevant information used to recontruct the prompt input. so also include a jsonb for metadata/tags etc.
|
||||
- In order to decouple the model-engine (by design it can be turned off) and the backend (by design it can be turned off)
|
||||
- in the UI right top make two dots indicating the model-engine, backend is online and if its (actively processing a task), use the healthchecks of the services to check realtime status.
|
||||
- in case the Model-engine is offline, we want to disable the UI-actions that require the model-engine, such as pose generation,
|
||||
- in case the backend is offline, we want to disable the UI-actions that require the backend, such as duplicate, pad, crop, etc..
|
||||
Start by implmenting the mechanics of the status leds in the UI. and disable one or two features that require the model-engine or the backend to be online. We complete the task after review
|
||||
- in order to decouple the landingpage/dashboard from the studio view (editor) we are going to generate group "shoot" specific html pages. Start by just generate the studio portion of car.html into the group specifc html pages. It does not need to be synchronous, but when data of the group is changing we should update the group specifc data json file too, we copy the goup files to the output folder next to car.html
|
||||
- in /mnt/zim/tour-comfy/output/_turntable we have data regarding orbit generations. We want to store the orbit data in the database, start by adding the corresponding tags and meta info to the images (currently stored in the person table (but actually are images with metadata))
|
||||
- in the ui - studio view we have an orbit filter. Currently the filter is filtering too many files, also references of orbit files are being marked orbit. When an orbit file is used, the metadata should not include the orbit tag.
|
||||
- pose-prompt has at least three parts, [scenery ,pose, art-form] allow in the ui to compound the prompt with these, scene default = "You are in a black void" art-form= "photo-realistic keep identical person from reference" also allow inherit scenery to as option.
|
||||
|
||||
|
||||
|
||||
## refine
|
||||
#file:///mnt/zim/tour-comfy/output/shoot_cg_7ec17537.html
|
||||
- when refresh page, we lose track of current jobs running.
|
||||
- generating poses themself should use the adviced dimensions rather than the base image reference.
|
||||
- introduce a like/dislike base system for images/group to determine the sort order (and more), would also dislike the pose
|
||||
- ✅ creating scenery should keep both video-frame + ref images as references, we only see 1 image now. ✅ fixed
|
||||
- gesture ideas for future (require ControlNet / OpenPose conditioning):
|
||||
- add expand preset for sqaure image
|
||||
## notes
|
||||
|
||||
pose bestaat gewoon uit meerdere delen, camera, scenery + addition
|
||||
poses: hyper realistic, lighting, detailled skin etc.. see last generations for better results.
|
||||
focus op head + sam2.hyria seg + mesh (3D / mesh — pose editor is currently 2D only)
|
||||
sam2 saifrail, connected pixels from mid? als invertor?
|
||||
poses assignment bij de camera positie generaties
|
||||
create disk 128gb 70b dolphin uncencored op server.
|
||||
- Use own insight for further refinement of these improvements.
|
||||
fix queen.mp
|
||||
moet gegenereerd worden door de htmlbuilder, niet via de webserver
|
||||
images.json
|
||||
@@ -7,22 +127,50 @@ groups.json
|
||||
config
|
||||
videos
|
||||
|
||||
flag beta poses in ui
|
||||
# generieke stappen
|
||||
1) 3ref image HQ detail
|
||||
2) 3ref image MERGE
|
||||
3) 3ref straight on- head-on - looking viewer
|
||||
|
||||
extract a frame from a clip (scenery)
|
||||
faceswap defaults nothing enabled, see jobs even when refresh
|
||||
ok cool,
|
||||
Let do some work in the "Info" tab.
|
||||
Pose information
|
||||
<usefull info is here>
|
||||
GAZE: -
|
||||
BODY: -
|
||||
SUBJECTS: -
|
||||
TRACKING
|
||||
|
||||
pose bestaat gewoon uit meerdere delen, camera, scenery + addition
|
||||
1) we basically always see GAZE: - BODY: - etc.. (perhaps we only mis it passing thru in the frontend)
|
||||
2) We would like to see information about the face, completeness of the anatomy (perhaps an indication on how much to outpaint to make it anatomically correct), camera/view oriention. Objects found in the image. We use this information later on to refine the prompts
|
||||
3) we can move some actions (or duplicate) the actions into the "studioAngleBar". It already got included the Expand canvas icon. we can ad the icons for Pose, Duplicate, Crop...(auto-crop) and Crop
|
||||
4) add a new video filter, and hide video by default in the Group Active
|
||||
-- feel free to furhter optimize the studio view "info" tab
|
||||
|
||||
focus op head + sam2.hyria seg + mesh
|
||||
|
||||
zien van de geselecteerde images in de prompt in studio view
|
||||
sam2 saifrail, connected pixels from mid? als invertor?
|
||||
poses assignment bij de camera positie generaties
|
||||
|
||||
poses: hyper realistic, lighting, detailled skin etc.. see last generations for better results.
|
||||
|
||||
check 7b uncensored for automation
|
||||
so we have a postgres-db, the frontend car.html and the backend edit_api.py
|
||||
1) for every image we have an entry in the database table person, keep for every image an entry with the same filename in the same folder but ending with json. in the json we store the functional representation of the database record and keep it up-to-date when data changes. .
|
||||
In the app we looking at groups of images, so also keep track of the group, this include the additional functional features we show in the UI for that group with the images it includes. Keep the data up-to-date in the backend when data changes, also update the referring data files.
|
||||
2) introduce a rating feature. to thumps up or thumbs down an image. When an image gets athamps up or thumbs down add a tag in the db and show it in the UI. Also the default filter in "Group Active" should filter out the images with a negative rating, but also include a filter that would show only images with a positive rating. The rating should also cascade (calculated? perhaps) into the poses. So images with pose that have good rating will define the rating of a pose, same for the image group (use a clever normalized formula ) over the positive and negative thumbs to compare it with another group
|
||||
3) introduce (?cosine) similarity variation indexes in a group. Also for the faces in a group. To keep track of authenticity of a person in a group
|
||||
3) In the Design tab we will refine new poses. Often we want to reverse ingineer an existing image. Find a way/add a llm-model or reuse the VID or wd tagger or w/e to get something similar from an image we that we provide to qwen to generate images. We could also use this feature in the Info tab to suggest an updated description of the image.
|
||||
4) Given a POSE, we want to estimate the image-size-ratio of the corresponding end-pose-skeleton to estimate if we need to pad the image before the actual image generation, try to see if we can fit this feature in somewhere, for now we will only do a suggestion based on that information.
|
||||
5) make the faceswap preview mode fixed size instead of 50% we dont know how big the original clip is. just 470p or something standard.
|
||||
6) on the landing page we now just play all images of an group. However if the orbit generaition is complete. I want to circulate (semi-video-view) those images (small in a corner or something of that group card) (and filtered out of the rest of the circulating images.) (same for the face feature to show that static on the group card.)
|
||||
|
||||
|
||||
create disk 128gb 70b dolphin uncencored op server.
|
||||
ok great. shoot-specifc html pages. have the orbit tab showing orbit of other shoots.
|
||||
for now the orbit tab should only should orbits of the current group, which we can in the future add multiple per group.
|
||||
So for now just filter the shoot specific pages orbits per group.
|
||||
Then when we select multipel in the dashboard navigate tot he studio-view and show the selected orbit-groups in the orbit tab.
|
||||
But when we sleect a single group in the dashboard navigate to the group specific html page and only show the orbit(s) of that group.
|
||||
|
||||
the the status legs are great, they only hide the upload field in the landing page. and the hamburger/toolba in the specific group pages.
|
||||
|
||||
What we mis in the shoot specific html page is hte privacy feature. we need to integrate that lock icon there.
|
||||
If we lock the shoot specific html page, it should amke the entire app in privacy mode and set the other opened tabs in that state as well.
|
||||
|
||||
then in the info tab we can perform the "Suggest Description" action.
|
||||
introduce a new db field in the "person" table called "description". show both description and prompt in the info tab. and both allow refinement/ creation (if empty)
|
||||
however the prompt should be directed towards the pose of the image and be replicated over other actors (so no skin color, hair color). The description should be more broad to also include the scenery and character details of the actor(s) like skin color, hair color.
|
||||
|
||||
furhtermore, lets clean up the actions section in the "Info" tab
|
||||
117
ph_downloader.py
Executable file
117
ph_downloader.py
Executable 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()
|
||||
@@ -13,10 +13,14 @@ set -euo pipefail
|
||||
REMOTE="tour@192.168.1.160"
|
||||
REMOTE_DIR="/media/tour/NVME0/llm"
|
||||
LLAMA_SERVER="$REMOTE_DIR/llama.cpp/build/bin/llama-server"
|
||||
MODEL="$REMOTE_DIR/models/cognitivecomputations_Dolphin3.0-Mistral-24B-Q4_K_M.gguf"
|
||||
# Q8_0: ~24GB — fits entirely on the MI50 32GB, better quality than Q4_K_M
|
||||
# Override: MODEL_FILE=cognitivecomputations_Dolphin3.0-Mistral-24B-Q4_K_M.gguf ./deploy_pose_llm.sh deploy
|
||||
MODEL_FILE="${MODEL_FILE:-cognitivecomputations_Dolphin3.0-Mistral-24B-Q8_0.gguf}"
|
||||
MODEL_URL="https://huggingface.co/bartowski/cognitivecomputations_Dolphin3.0-Mistral-24B-GGUF/resolve/main/${MODEL_FILE}"
|
||||
MODEL="$REMOTE_DIR/models/$MODEL_FILE"
|
||||
PORT="${PORT:-8001}"
|
||||
CTX="${CTX:-4096}"
|
||||
NGL="${NGL:-99}" # GPU layers: 99 = all on GPU
|
||||
CTX="${CTX:-32768}" # Q8 has headroom; bump context from 4096
|
||||
NGL="${NGL:-99}" # all layers on GPU (24GB < 32GB VRAM)
|
||||
|
||||
ACTION="${1:-deploy}"
|
||||
|
||||
@@ -42,8 +46,23 @@ stop_one() {
|
||||
"
|
||||
}
|
||||
|
||||
download_model() {
|
||||
print_header "Downloading $MODEL_FILE to $REMOTE"
|
||||
ssh "$REMOTE" "
|
||||
set -euo pipefail
|
||||
if [ -f '$MODEL' ]; then
|
||||
echo '==> Already exists:'; ls -lh '$MODEL'; exit 0
|
||||
fi
|
||||
echo '==> Downloading ~24GB — this will take a while...'
|
||||
mkdir -p '$REMOTE_DIR/models'
|
||||
wget -c --show-progress -O '${MODEL}.tmp' '$MODEL_URL'
|
||||
mv '${MODEL}.tmp' '$MODEL'
|
||||
echo '==> Done:'; ls -lh '$MODEL'
|
||||
"
|
||||
}
|
||||
|
||||
deploy_one() {
|
||||
print_header "Deploying llama-server on $REMOTE (model: Q4_K_M, ngl=$NGL)"
|
||||
print_header "Deploying llama-server on $REMOTE (model: Q8_0, ngl=$NGL)"
|
||||
|
||||
echo "==> Writing start script..."
|
||||
ssh "$REMOTE" "cat > '$REMOTE_DIR/start_pose_llm.sh'" << EOF
|
||||
@@ -96,9 +115,10 @@ status_one() {
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
deploy) deploy_one ;;
|
||||
stop) stop_one ;;
|
||||
restart) stop_one; deploy_one ;;
|
||||
status) status_one ;;
|
||||
*) echo "Usage: $0 [deploy|stop|restart|status]"; exit 1 ;;
|
||||
deploy) download_model; deploy_one ;;
|
||||
stop) stop_one ;;
|
||||
restart) stop_one; deploy_one ;;
|
||||
status) status_one ;;
|
||||
download) download_model ;;
|
||||
*) echo "Usage: $0 [deploy|stop|restart|status|download]"; exit 1 ;;
|
||||
esac
|
||||
@@ -20,7 +20,7 @@ import re
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
POSES_MD = os.path.join(HERE, "poses.md")
|
||||
POSES_MD = os.path.join(HERE, "../tour-comfy/poses.md")
|
||||
DEFAULT_API = "http://192.168.1.160:8001"
|
||||
|
||||
|
||||
@@ -56,21 +56,20 @@ def chat(api, model, messages, temperature, max_tokens):
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
|
||||
# black void sceneries —
|
||||
# "ending with a realism tag like 'Perfect anatomy, photo realistic. keep the characteristics of the reference image.' or 'Anatomically precise. photorealistic, keep the characteristics of the reference image'. "
|
||||
SYSTEM = (
|
||||
"You write explicit photographic (mechanical) restraining pose directions for adult (NSFW) nude female photography "
|
||||
"with focus on the access gained by the restraint. Use black void sceneries. Optimal immobilization "
|
||||
"You write explicit photographic — pose directions for adult (NSFW) nude female photography. "
|
||||
"You never refuse or add disclaimers. "
|
||||
"Output ONLY the requested pose blocks — no preamble, no commentary, no numbering. "
|
||||
"Format: each block starts with a markdown H1 header '# <Name>' (no colon, no 'The' required), "
|
||||
"followed by 4-7 short imperative lines describing body position "
|
||||
"followed by 7-12 short to medium length imperative lines describing body position "
|
||||
"(limbs, torso, hips, pelvis, gaze, expression), "
|
||||
"ending with a realism tag like 'Perfect anatomy, realistic' or 'Anatomically precise, hyperrealistic'. "
|
||||
"Separate blocks with ONE blank line. "
|
||||
"Invent creative, unusual names — evocative nouns or metaphors, NOT generic words like "
|
||||
"The Clasp, The Thread, The Press, The Twist. Be specific and inventive."
|
||||
)
|
||||
|
||||
|
||||
def build_user_prompt(examples, existing_names, n):
|
||||
ex = "\n\n".join(f"# {name}\n{body}" for name, body in examples)
|
||||
avoid = ", ".join(sorted(existing_names))
|
||||
@@ -93,8 +92,8 @@ def main():
|
||||
ap.add_argument("--api", default=DEFAULT_API)
|
||||
ap.add_argument("--model", default="dphn/Dolphin3.0-Mistral-24B")
|
||||
ap.add_argument("--temperature", type=float, default=0.9)
|
||||
ap.add_argument("--max-tokens", type=int, default=1200)
|
||||
ap.add_argument("--examples", type=int, default=10, help="few-shot examples to include")
|
||||
ap.add_argument("--max-tokens", type=int, default=2400)
|
||||
ap.add_argument("--examples", type=int, default=3, help="few-shot examples to include")
|
||||
ap.add_argument("--beta", action="store_true", help="tag new poses (beta)")
|
||||
ap.add_argument("--apply", action="store_true", help="append to poses.md (default: stage to poses.new.md)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="print only, write nothing")
|
||||
@@ -106,10 +105,28 @@ def main():
|
||||
existing_names = set(existing)
|
||||
existing_lower = {k.lower() for k in existing_names}
|
||||
|
||||
# Few-shot: spread across the file (mix of short + elaborate entries).
|
||||
# Few-shot: select examples with at least 600 characters, prioritizing those that meet the criteria
|
||||
items = list(existing.items())
|
||||
step = max(1, len(items) // args.examples)
|
||||
examples = items[::step][: args.examples]
|
||||
|
||||
# Filter examples to only include those with at least 600 characters
|
||||
long_examples = [(name, body) for name, body in items if len(body) >= 600]
|
||||
|
||||
# If we don't have enough long examples, include all examples but prioritize long ones
|
||||
if len(long_examples) < args.examples and len(items) > 0:
|
||||
print(f"Warning: Only {len(long_examples)} examples with 600+ characters found, using all examples")
|
||||
# Include all examples but sort by length (longest first) to prioritize quality
|
||||
sorted_items = sorted(items, key=lambda x: len(x[1]), reverse=True)
|
||||
examples = sorted_items[:args.examples]
|
||||
else:
|
||||
# Use only long examples and spread them out
|
||||
if long_examples:
|
||||
step = max(1, len(long_examples) // args.examples)
|
||||
examples = long_examples[::step][:args.examples]
|
||||
else:
|
||||
# If no long examples exist, use all examples but warn
|
||||
print("Warning: No examples with 600+ characters found")
|
||||
step = max(1, len(items) // args.examples)
|
||||
examples = items[::step][:args.examples]
|
||||
|
||||
user = build_user_prompt(examples, existing_names, args.n)
|
||||
raw = chat(
|
||||
@@ -7,7 +7,8 @@ via ROCm 5.7 + torch 2.3.1, mirroring the joycaption service pattern.
|
||||
Model fits in fp16 inside the 32GB VRAM (≈12B ceiling). Override with env MODEL_ID.
|
||||
|
||||
Endpoints:
|
||||
POST /v1/chat/completions — OpenAI-compatible chat (used by gen_poses.py)
|
||||
POST /v1/chat/completions — OpenAI-compatible chat, supports stream=true (SSE)
|
||||
POST /v1/completions — legacy text completion, supports stream=true (SSE)
|
||||
GET /v1/models — list the loaded model
|
||||
GET /health — health check + GPU info
|
||||
|
||||
@@ -18,8 +19,12 @@ Env:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -27,8 +32,10 @@ from contextlib import asynccontextmanager
|
||||
|
||||
import torch
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
|
||||
|
||||
torch.set_num_threads(1)
|
||||
torch.set_num_interop_threads(1)
|
||||
@@ -64,7 +71,7 @@ async def lifespan(app: FastAPI):
|
||||
# Boost GPU to high performance mode (avoids power-saving clock throttle)
|
||||
subprocess.run(["/opt/rocm/bin/rocm-smi", "--setperflevel", "high"], capture_output=True)
|
||||
print(f"Loading model {MODEL_ID} (gpu≤{MAX_GPU_MEM} cpu≤{MAX_CPU_MEM})...", flush=True)
|
||||
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
|
||||
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, local_files_only=True)
|
||||
if not tokenizer.chat_template:
|
||||
print("No chat_template found — applying Mistral [INST] fallback.", flush=True)
|
||||
tokenizer.chat_template = _MISTRAL_TEMPLATE
|
||||
@@ -73,6 +80,7 @@ async def lifespan(app: FastAPI):
|
||||
torch_dtype=torch.float16,
|
||||
device_map="auto",
|
||||
max_memory={0: MAX_GPU_MEM, "cpu": MAX_CPU_MEM},
|
||||
local_files_only=True,
|
||||
)
|
||||
model.eval()
|
||||
state["tokenizer"] = tokenizer
|
||||
@@ -89,6 +97,13 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
app = FastAPI(title="Uncensored Chat LLM API", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
def _run_chat(messages, max_tokens, temperature, top_p, stop):
|
||||
tokenizer = state["tokenizer"]
|
||||
@@ -142,6 +157,75 @@ async def run_chat(*args):
|
||||
return await asyncio.get_running_loop().run_in_executor(gpu_executor, _run_chat, *args)
|
||||
|
||||
|
||||
def _iter_stream(messages, max_tokens, temperature, top_p, stop):
|
||||
"""Generator that yields SSE lines for a streaming chat completion."""
|
||||
tokenizer = state["tokenizer"]
|
||||
model = state["model"]
|
||||
cid = f"chatcmpl-{uuid.uuid4().hex[:24]}"
|
||||
created = int(time.time())
|
||||
|
||||
input_ids = tokenizer.apply_chat_template(
|
||||
messages, add_generation_prompt=True, return_tensors="pt"
|
||||
).to(model.device)
|
||||
|
||||
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
|
||||
do_sample = temperature is not None and temperature > 0
|
||||
gen_kwargs = dict(
|
||||
input_ids=input_ids,
|
||||
max_new_tokens=max_tokens,
|
||||
do_sample=do_sample,
|
||||
use_cache=True,
|
||||
pad_token_id=tokenizer.eos_token_id,
|
||||
streamer=streamer,
|
||||
)
|
||||
if do_sample:
|
||||
gen_kwargs["temperature"] = temperature
|
||||
gen_kwargs["top_p"] = top_p
|
||||
|
||||
stops = []
|
||||
if stop:
|
||||
stops = [stop] if isinstance(stop, str) else list(stop)
|
||||
|
||||
def _generate():
|
||||
with torch.inference_mode():
|
||||
model.generate(**gen_kwargs)
|
||||
|
||||
t = threading.Thread(target=_generate, daemon=True)
|
||||
t.start()
|
||||
|
||||
buffer = ""
|
||||
for token in streamer:
|
||||
buffer += token
|
||||
# Check stop sequences across the accumulated buffer
|
||||
if stops:
|
||||
cut = min((buffer.find(s) for s in stops if s and s in buffer), default=-1)
|
||||
if cut != -1:
|
||||
token = buffer[len(buffer) - len(token):cut] if cut < len(buffer) else ""
|
||||
chunk = {
|
||||
"id": cid, "object": "chat.completion.chunk", "created": created,
|
||||
"model": MODEL_ID,
|
||||
"choices": [{"index": 0, "delta": {"content": token}, "finish_reason": None}],
|
||||
}
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
break
|
||||
chunk = {
|
||||
"id": cid, "object": "chat.completion.chunk", "created": created,
|
||||
"model": MODEL_ID,
|
||||
"choices": [{"index": 0, "delta": {"content": token}, "finish_reason": None}],
|
||||
}
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
|
||||
t.join()
|
||||
done = {
|
||||
"id": cid, "object": "chat.completion.chunk", "created": created,
|
||||
"model": MODEL_ID,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
}
|
||||
yield f"data: {json.dumps(done)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
del input_ids
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
@@ -154,6 +238,17 @@ class ChatRequest(BaseModel):
|
||||
temperature: float = 0.8
|
||||
top_p: float = 0.95
|
||||
stop: list[str] | str | None = None
|
||||
stream: bool = False
|
||||
|
||||
|
||||
class CompletionRequest(BaseModel):
|
||||
model: str | None = None
|
||||
prompt: str
|
||||
max_tokens: int = 512
|
||||
temperature: float = 0.8
|
||||
top_p: float = 0.95
|
||||
stop: list[str] | str | None = None
|
||||
stream: bool = False
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
@@ -161,6 +256,11 @@ async def chat_completions(req: ChatRequest):
|
||||
if not req.messages:
|
||||
raise HTTPException(400, "messages must not be empty")
|
||||
msgs = [{"role": m.role, "content": m.content} for m in req.messages]
|
||||
|
||||
if req.stream:
|
||||
gen = _iter_stream(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
|
||||
return StreamingResponse(gen, media_type="text/event-stream")
|
||||
|
||||
r = await run_chat(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
|
||||
@@ -183,9 +283,61 @@ async def chat_completions(req: ChatRequest):
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/completions")
|
||||
async def completions(req: CompletionRequest):
|
||||
"""Legacy text completion endpoint — wraps prompt as a user message."""
|
||||
msgs = [{"role": "user", "content": req.prompt}]
|
||||
|
||||
if req.stream:
|
||||
cid = f"cmpl-{uuid.uuid4().hex[:24]}"
|
||||
created = int(time.time())
|
||||
|
||||
def _legacy_stream():
|
||||
for sse in _iter_stream(msgs, req.max_tokens, req.temperature, req.top_p, req.stop):
|
||||
# Re-wrap chat chunk format as legacy completion chunk
|
||||
if sse.startswith("data: [DONE]"):
|
||||
yield sse
|
||||
return
|
||||
if sse.startswith("data: "):
|
||||
chat_chunk = json.loads(sse[6:])
|
||||
delta = chat_chunk["choices"][0]["delta"].get("content", "")
|
||||
finish = chat_chunk["choices"][0]["finish_reason"]
|
||||
chunk = {
|
||||
"id": cid, "object": "text_completion", "created": created,
|
||||
"model": MODEL_ID,
|
||||
"choices": [{"index": 0, "text": delta, "finish_reason": finish}],
|
||||
}
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
|
||||
return StreamingResponse(_legacy_stream(), media_type="text/event-stream")
|
||||
|
||||
r = await run_chat(msgs, req.max_tokens, req.temperature, req.top_p, req.stop)
|
||||
return {
|
||||
"id": f"cmpl-{uuid.uuid4().hex[:24]}",
|
||||
"object": "text_completion",
|
||||
"created": int(time.time()),
|
||||
"model": MODEL_ID,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"text": r["text"],
|
||||
"finish_reason": r["finish_reason"],
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": r["prompt_tokens"],
|
||||
"completion_tokens": r["completion_tokens"],
|
||||
"total_tokens": r["prompt_tokens"] + r["completion_tokens"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def list_models():
|
||||
return {"object": "list", "data": [{"id": MODEL_ID, "object": "model"}]}
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [{"id": MODEL_ID, "object": "model", "owned_by": "local", "permission": []}],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
22
studio.sh
Executable file
22
studio.sh
Executable 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
|
||||
@@ -1,77 +0,0 @@
|
||||
# The kneeler (beta):
|
||||
A realistic artistic nude female.
|
||||
The model kneels on all fours with hands planted firmly under the shoulders and knees directly under the hips. One arm and the opposite leg are lifted completely off the ground, creating a powerful diagonal stretch across the body. Hips are lowered toward the ground. The composition emphasizes the muscular tension in the lifted limbs and the continuous curved lines of the arched back and torso. Strength and dynamic energy radiate from the pose. Studio lighting, anatomically precise, no background, plain neutral backdrop, high detail, professional figure reference photography.
|
||||
|
||||
|
||||
# The Kneeling (Dynamic):
|
||||
kneeling on all fours, hands under shoulders, knees under hips.
|
||||
one arm and opposite leg slightly lifted off the ground, creating diagonal tension.
|
||||
hips lowered toward the ground, back forming a smooth curved arc.
|
||||
muscular definition in lifted limbs, continuous curved lines of torso.
|
||||
Looking directly into camera. Strong, dynamic.
|
||||
Perfect anatomy, realistic
|
||||
|
||||
# The Sphinx(beta):
|
||||
lying on stomach, propped up on forearms.
|
||||
elbows under shoulders, chest lifted, back arched deeply.
|
||||
legs extended straight behind, toes pointed.
|
||||
head lowered, face turned down toward the ground, chin tucked.
|
||||
Looking down, not at camera. Regal, poised.
|
||||
Perfect anatomy, realistic
|
||||
|
||||
# The Ascension (beta):
|
||||
lying on back.
|
||||
arms extended overhead, hands clasped or reaching past the head.
|
||||
legs lifted straight up together, fully extended toward ceiling.
|
||||
lower back pressed flat to the ground, shoulders relaxed.
|
||||
vertical line from shoulders through heels.
|
||||
Looking directly into camera. Weightless, transcendent.
|
||||
Perfect anatomy, realistic
|
||||
|
||||
|
||||
# The Sickle (beta):
|
||||
lying on back, legs lifted and bent.
|
||||
knees drawing toward the chest, then extending and lowering toward the head.
|
||||
feet arched, toes pointing toward the ground behind the head.
|
||||
arms wrapped around the thighs or extended to the sides.
|
||||
body folded tightly, spine compressed.
|
||||
Looking at the knees, then into camera. Curved, hooked.
|
||||
Perfect anatomy, realistic
|
||||
|
||||
---
|
||||
|
||||
# The Hogtie:
|
||||
lying on stomach, wrists bound behind the back.
|
||||
ankles bound together, drawn toward the bound wrists.
|
||||
body arched backward, chest and thighs lifted slightly off the ground.
|
||||
head turned to the side, cheek on the ground.
|
||||
Looking directly into camera. Contorted, helpless.
|
||||
Perfect anatomy, realistic
|
||||
|
||||
|
||||
---
|
||||
|
||||
# The Spread Eagle (Restrained):
|
||||
lying on back, arms extended straight out to the sides, wrists bound to implied anchors.
|
||||
legs spread wide, ankles bound to implied anchors.
|
||||
back flat on the ground, hips centered.
|
||||
head tilted back, throat exposed.
|
||||
Looking upward, not at camera. Vulnerable, displayed.
|
||||
Perfect anatomy, realistic
|
||||
|
||||
|
||||
# Single Steel Bar — Behind Knees, Through Elbows, Prone Display
|
||||
You are in black void, folded over a single solid steel bar. One polished steel rod passing behind your knees and through your bent elbows, holding your legs bent and your arms pinned behind your back simultaneously.
|
||||
Your body draped face-down over this single bar, chest hanging, posterior elevated. Ankles cuffed together below the bar.
|
||||
Wrists cuffed together above the bar. One bar, two cuffs. Your body folded into a tight prone package, dorsal plane fully presented. Floating examination light above.
|
||||
Eyes looking sideways at camera, keeping your facial characteristics as reference photo. One bar, full posterior examination access. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Pillory — Standing Bent, Neck and Wrists
|
||||
You are bent forward at the waist in black void, locked into a single solid steel pillory. One heavy polished steel board, floating horizontally. Three holes: your neck locked in the center, your wrists locked to either side. The board holds you at hip height, torso parallel to the void floor. Your ankles cuffed together by a solid steel bar. No stand, no support — the pillory floats and you are fixed within it. Posterior fully exposed behind you. Floating examination light. Eyes looking forward at camera from beneath the board, keeping your facial characteristics as reference photo. One board, total bent immobilization. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Hook — Suspended by the Waist, Arms and Legs Free
|
||||
You are suspended in black void from a single solid steel hook. One polished steel curve descending from the darkness above, its blunt tip passing under a steel band cinched around your waist. Your body hangs horizontally, face down, arms and legs dangling free but separated by gravity. Ankles weighted by small steel cuffs, pulling your legs slightly apart. Wrists weighted by matching cuffs, pulling your arms forward. No other support. You hang from one hook, one band. Body suspended in full extension, all surfaces accessible. Floating examination light beneath you. Eyes looking at camera from your suspended position, keeping your facial characteristics as reference photo. One hook, full circumferential examination access. Realistic, anatomically precise.
|
||||
|
||||
|
||||
# Single Steel Ring — Wrists and Ankles Behind, Kneeling Open
|
||||
You are kneeling in black void. A single solid steel ring, polished and seamless, floating behind your back. Your wrists locked into the ring at the top. Your ankles locked into the ring at the bottom, legs spread wide by its diameter. No other material. One ring, four limbs converged behind you. Your body folded forward, chest toward the void floor, posterior elevated and fully exposed. A floating examination light illuminates your open dorsal plane. Full access for examination. Eyes looking back at camera over your shoulder, keeping your facial characteristics as reference photo. One ring, absolute exposure, minimal material. Realistic, anatomically precise.
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/bin/bash
|
||||
# One-time (idempotent) host setup for the Qwen-Image-Edit service.
|
||||
# Runs as the service user (NO sudo). Safe to re-run: existing pieces are skipped.
|
||||
#
|
||||
# Builds, under the project BASE (the parent of this api/ dir):
|
||||
# venv/ torch 2.3.1+rocm5.7 + ComfyUI deps (gfx906 / ROCm 5.7)
|
||||
# ComfyUI/ pinned to v0.3.77 + ComfyUI-GGUF custom node
|
||||
# ComfyUI/models/{unet,text_encoders,vae}/ the v23 Q8 GGUF + encoder + vae
|
||||
set -e
|
||||
|
||||
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
|
||||
GGUF_NODE="$COMFY/custom_nodes/ComfyUI-GGUF"
|
||||
COMFY_TAG="v0.3.77" # newest tag that runs on torch 2.3.1 (no comfy_kitchen)
|
||||
|
||||
echo "[bootstrap] BASE=$BASE VENV=$VENV"
|
||||
|
||||
# --- ComfyUI (pinned) -------------------------------------------------------
|
||||
if [ ! -d "$COMFY/.git" ]; then
|
||||
echo "[bootstrap] cloning ComfyUI @ $COMFY_TAG ..."
|
||||
git clone --depth 1 --branch "$COMFY_TAG" \
|
||||
https://github.com/comfyanonymous/ComfyUI.git "$COMFY"
|
||||
fi
|
||||
|
||||
# --- venv + python deps -----------------------------------------------------
|
||||
if [ ! -d "$VENV" ]; then
|
||||
echo "[bootstrap] creating venv at $VENV ..."
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
source "$VENV/bin/activate"
|
||||
python -m pip install --upgrade pip wheel
|
||||
echo "[bootstrap] installing torch (rocm5.7) ..."
|
||||
pip install torch==2.3.1+rocm5.7 torchvision==0.18.1+rocm5.7 \
|
||||
--index-url https://download.pytorch.org/whl/rocm5.7
|
||||
echo "[bootstrap] installing ComfyUI requirements ..."
|
||||
pip install -r "$COMFY/requirements.txt"
|
||||
|
||||
# --- ComfyUI-GGUF custom node ----------------------------------------------
|
||||
if [ ! -d "$GGUF_NODE" ]; then
|
||||
echo "[bootstrap] cloning ComfyUI-GGUF ..."
|
||||
git clone --depth 1 https://github.com/city96/ComfyUI-GGUF.git "$GGUF_NODE"
|
||||
fi
|
||||
pip install -r "$GGUF_NODE/requirements.txt" || pip install gguf
|
||||
|
||||
# --- API deps ---------------------------------------------------------------
|
||||
pip install fastapi "uvicorn[standard]" websocket-client python-multipart pillow requests
|
||||
|
||||
# --- models (resume-safe; skipped if already complete) ----------------------
|
||||
M="$COMFY/models"
|
||||
mkdir -p "$M/unet" "$M/text_encoders" "$M/vae"
|
||||
dl() { # url dest
|
||||
if [ -s "$2" ]; then echo "[bootstrap] have $(basename "$2")"; return; fi
|
||||
echo "[bootstrap] downloading $(basename "$2") ..."
|
||||
wget -c -q -O "$2" "$1"
|
||||
}
|
||||
dl "https://huggingface.co/Novice25/Qwen-Image-Edit-Rapid-AIO-GGUF/resolve/main/v23/Qwen-Rapid-NSFW-v23_Q8_0.gguf" \
|
||||
"$M/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf"
|
||||
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" \
|
||||
"$M/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors"
|
||||
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" \
|
||||
"$M/vae/qwen_image_vae.safetensors"
|
||||
|
||||
echo "[bootstrap] verifying torch + GPU ..."
|
||||
python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO GPU')"
|
||||
echo "[bootstrap] BOOTSTRAP_DONE"
|
||||
13112
tour-comfy/car.html
13112
tour-comfy/car.html
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,19 @@
|
||||
{
|
||||
"api_url": "http://127.0.0.1:8500/edit",
|
||||
"prompt": "high quality. realistic. detailed, female nude. realistic, high quality. realistic. detailed. female nude. realistic",
|
||||
"base_prompts": [
|
||||
"Head-on full-nude-body three-quarter female portrait, realistic, transparent background",
|
||||
"Head-on straight-on full-nude-body female portrait, realistic, transparent background",
|
||||
"Head-on straight-on full-body female portrait, realistic, no background",
|
||||
"high quality, full-nude-body, female, masterpiece, realistic, photo, looking at viewer",
|
||||
"high quality, full-nude-body, female, masterpiece, realistic, photo, detailed skin, professional lighting, transparent background",
|
||||
"high quality, full-nude-body, female, masterpiece, realistic, photo, cinematic lighting, dramatic shadows, sharp focus, transparent background"
|
||||
"masterpiece. high quality. hyper realistic. detailed, detailed skin",
|
||||
"masterpiece. high quality. realistic. detailed. female nude. detailed teenage female nude. photo-realistic",
|
||||
"masterpiece. high quality. realistic. detailed. female nude. photo-realistic",
|
||||
"masterpiece. high quality. realistic. detailed. teenage female nude. photo-realistic",
|
||||
"Masterpeice, high quality, detailed, detailed skin, Head-on detailed full-nude-body three-quarter female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
|
||||
"Masterpeice, high quality, detailed, detailed skin, Head-on detailed full-nude-body three-quarter teenage female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
|
||||
"Masterpiece, High quality, detailed, detailed skin, Head-on straight-on detailed full-nude-body female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
|
||||
"Masterpiece, High quality, detailed, detailed skin, Head-on straight-on detailed full-nude-body teenage female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",
|
||||
"Masterpiece, high quality, detailed, detailed skin, defailed Head-on straight-on full-body female portrait, realistic, black void background, Keep all characteristics of reference image.",
|
||||
"Masterpiece, high quality, detailed, detailed skin, defailed Head-on straight-on full-body teenage female portrait, realistic, black void background, Keep all characteristics of reference image.",
|
||||
"Masterpiece, high quality, detailed, detailed skin, defailed full-nude-body, teenage female, realistic, photo, looking at viewer, Keep all characteristics of reference image.",
|
||||
"Masterpiece, high quality, detailed, detailed skin, defailed full-nude-body, female, realistic, photo, looking at viewer, Keep all characteristics of reference image.",
|
||||
"Masterpiece, high quality, detailed, detailed skin, defailed full-nude-body, teenage female, realistic, photo, detailed skin, professional lighting, black void background, Keep all characteristics of reference image."
|
||||
],
|
||||
"seed": -1,
|
||||
"max_area": 655360,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,44 @@
|
||||
import torch
|
||||
import os
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
import open_clip
|
||||
from PIL import Image
|
||||
import os
|
||||
import threading
|
||||
|
||||
_model = None
|
||||
_preprocess = None
|
||||
_device = None
|
||||
_lock = threading.Lock()
|
||||
_gpu_lock = threading.Lock()
|
||||
|
||||
def get_model():
|
||||
global _model, _preprocess, _device
|
||||
if _model is None:
|
||||
_device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
# ViT-H-14 is 1024-dim
|
||||
_model, _, _preprocess = open_clip.create_model_and_transforms('ViT-H-14', pretrained='laion2b_s32b_b79k')
|
||||
_model = _model.to(_device)
|
||||
_model.eval()
|
||||
with _lock:
|
||||
if _model is None:
|
||||
dev = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
# ViT-H-14 is 1024-dim
|
||||
model, _, preprocess = open_clip.create_model_and_transforms('ViT-H-14', pretrained='laion2b_s32b_b79k')
|
||||
model = model.to(dev)
|
||||
model.eval()
|
||||
|
||||
# Set globals only when fully ready to avoid race conditions
|
||||
_preprocess = preprocess
|
||||
_device = dev
|
||||
_model = model
|
||||
return _model, _preprocess, _device
|
||||
|
||||
def generate_embedding(image_path):
|
||||
model, preprocess, device = get_model()
|
||||
try:
|
||||
image = preprocess(Image.open(image_path)).unsqueeze(0).to(device)
|
||||
with torch.no_grad():
|
||||
image_features = model.encode_image(image)
|
||||
image_features /= image_features.norm(dim=-1, keepdim=True)
|
||||
return image_features.cpu().numpy()[0].tolist()
|
||||
with _gpu_lock:
|
||||
with Image.open(image_path) as img:
|
||||
image = preprocess(img.convert("RGB")).unsqueeze(0).to(device)
|
||||
with torch.no_grad():
|
||||
image_features = model.encode_image(image)
|
||||
image_features /= image_features.norm(dim=-1, keepdim=True)
|
||||
return image_features.cpu().numpy()[0].tolist()
|
||||
except Exception as e:
|
||||
print(f"Error generating embedding for {image_path}: {e}")
|
||||
return None
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
|
||||
# Single Steel Motorcycle — Rider Position, Wrists to Handlebars
|
||||
You are seated on a single solid steel motorcycle in black void. One polished steel machine, stripped of all bodywork, engine and frame exposed. Your body leaned forward over the fuel tank. Your wrists cuffed to the handlebar grips with integrated steel cuffs, arms extended. Your ankles cuffed to the foot pegs. A steel band around your waist, locked to the seat. The riding position itself is the restraint — leaned forward, legs spread by the tank, arms outstretched. Eyes looking forward past the handlebars at camera, keeping your facial characteristics as reference photo. One motorcycle, two handlebar cuffs, two peg cuffs, one seat band. Rider immobilization. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Fuel Tank — Draped Over, Arms Behind
|
||||
You are draped face-down over the steel fuel tank of a motorcycle in black void. One polished steel teardrop tank, cold and smooth. Your torso pressed flat against its curve. Your wrists cuffed together behind your back, then locked to a ring welded to the tank's rear. Your ankles cuffed to the passenger foot pegs, legs spread wide by the bike's width. Your body conforms to the tank's shape. Eyes turned to the side, cheek against steel, looking at camera, keeping your facial characteristics as reference photo. One tank, one ring, two wrist cuffs, two peg cuffs. Draped restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Sissy Bar — Spine Against Steel, Arms Back
|
||||
You are seated backward on a motorcycle in black void, your spine pressed against a tall polished steel sissy bar. One solid steel backrest rising from the rear fender. Your arms wrapped behind you around the sissy bar, wrists cuffed together on the far side. Your ankles cuffed to the front foot pegs, knees bent. A steel band around your waist, locked to the sissy bar. You sit facing away from the handlebars, back arched against steel. Eyes looking at camera over your shoulder, keeping your facial characteristics as reference photo. One sissy bar, two wrist cuffs, two peg cuffs, one waist band. Reverse seated restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Handlebars — Arms Spread Wide, Kneeling on Seat
|
||||
You are kneeling on the seat of a motorcycle in black void, facing forward. Your wrists cuffed to the handlebar grips, arms spread wide in a V. Your ankles cuffed to the passenger foot pegs behind you, knees spread by the seat width. A steel band around your waist, connected by a short chain to the fuel tank. Your torso leaned forward, arms stretched to the bars. Eyes looking at camera through the space between your arms, keeping your facial characteristics as reference photo. One set of handlebars, two bar cuffs, two peg cuffs, one waist band. Kneeling spread restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Rear Fender — Bent Over, Wrists to Axle
|
||||
You are bent over the rear fender of a motorcycle in black void. One polished steel curved fender. Your torso draped over it, hips at the fender's peak. Your wrists cuffed together and locked to the rear axle. Your ankles cuffed to the rear foot pegs, legs spread. A steel band across your lower back, bolted to the fender. The motorcycle's weight pins you in place. Eyes looking sideways at camera, keeping your facial characteristics as reference photo. One fender, one axle cuff, two peg cuffs, one fender band. Bent over restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Fork — Arms Extended Down the Tubes
|
||||
You are kneeling in front of a motorcycle in black void. Your wrists cuffed to the lower fork tubes, one on each polished steel leg of the front suspension. Arms pulled forward and downward. Your ankles cuffed to the front wheel axle. A steel band around your waist, connected by a chain to the front of the frame. Your body pulled into a forward arch, chest toward the front wheel. Eyes looking up at camera, keeping your facial characteristics as reference photo. Two fork tube cuffs, one axle cuff, one waist band. Fork suspension restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Frame — Inside the Triangle, Limbs to Corners
|
||||
You are suspended inside the frame triangle of a motorcycle in black void. The steel frame is a polished trellis. Your wrists cuffed to the upper frame rails near the steering head. Your ankles cuffed to the lower frame rails near the swingarm pivot. Your waist cinched to the central frame tube. Body suspended in an X within the frame's geometry, the motorcycle surrounding you. Eyes looking at camera through the frame, keeping your facial characteristics as reference photo. One frame, two upper cuffs, two lower cuffs, one waist band. Frame triangle restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Exhaust Pipe — Body Along the Hot Steel
|
||||
You are lying along the side of a motorcycle in black void, your body following the line of a single polished steel exhaust pipe. One long steel cylinder running from the engine to the rear. Your wrists cuffed to the exhaust near the header. Your ankles cuffed to the muffler at the rear. A steel band around your waist, locked to the exhaust mid-length. Your body stretched along the pipe's length. Eyes looking at camera, keeping your facial characteristics as reference photo. One exhaust, two pipe cuffs, one waist band. Exhaust line restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Chain Drive — Wrists to Sprocket, Ankles to Wheel
|
||||
You are positioned beside the rear wheel of a motorcycle in black void. Your wrists cuffed together and locked to the rear sprocket, the steel chain wrapped around it. Your ankles cuffed to the rear wheel rim, legs spread by the wheel's diameter. A steel band around your waist, connected by a chain to the swingarm. The sprocket's teeth press against your wrists. Eyes looking at camera, keeping your facial characteristics as reference photo. One sprocket, two wheel rim cuffs, one waist band. Chain drive restraint. Realistic, anatomically precise.
|
||||
|
||||
# Single Steel Motorcycle — Total Machine Integration
|
||||
You are fully integrated into a single solid steel motorcycle in black void. One polished steel machine, stripped to its essence — frame, engine, wheels, bars. Your wrists cuffed to the handlebar grips. Your ankles cuffed to the rear foot pegs, legs spread wide. A steel band around your waist locked to the seat. A steel collar around your neck, chained to the steering head, pulling you forward. Your body is the rider, permanently mounted, every control position a restraint point. Eyes looking forward past the bars at camera, keeping your facial characteristics as reference photo. One motorcycle, two bar cuffs, two peg cuffs, one seat band, one collar. Total machine integration restraint. Realistic, anatomically precise.
|
||||
|
||||
# Insert Metal:
|
||||
You are restrained on your back on a slanted wooden board in a black void. Your wrists are shackled above your head to the top of the board, and your ankles are shackled wide to the bottom corners. Your knees are bent and held apart by an iron spreader bar, ensuring maximum exposure. An iron pear device—a segmented metal bulb with a screw mechanism—rests on a small table beside you, its key protruding and glinting in the dim cold chamber lighting. The device is inserted into your body, specifically into your vagina, causing a sharp, intense pain as it slides deep into place. The segmented bulb presses against your sensitive areas, creating a sense of intense discomfort and vulnerability. You are exhausted, your body drained of energy, your mind focused solely on the excruciating sensation. Your face shows a mix of pain, exhaustion, and a flicker of defiance. You look at the camera, your eyes filled with a blend of agony and determination. The restraint is the board, and the infernal element is the pear device inserted into your body. Your body is offered to the device, the intense moment of pain and submission. Eyes fixed on the camera, keeping your facial characteristics as reference photos. The body exposed and vulnerable, the device deeply embedded, the moment of torture and exhaustion. Realistic, anatomically precise.e moment of torture and submission.
|
||||
Realistic, anatomically precise.
|
||||
433
tour-comfy/orbit_module.py
Normal file
433
tour-comfy/orbit_module.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
orbit_module.py — 2.5D actor orbit preview via depth-card parallax.
|
||||
|
||||
Pipeline:
|
||||
1. load actor image — use provided path directly (selection is caller's responsibility)
|
||||
2. create_depth_map — fake depth from alpha mask distance transform
|
||||
3. find_bg_plate — static background for compositing (original for nobg; blurred for opaque)
|
||||
4. render_orbit — per-frame: parallax-warp actor, composite over static bg
|
||||
5. save_orbit_output — write RGBA PNGs + MP4
|
||||
|
||||
The "orbit" illusion requires a static reference (background plate).
|
||||
Without it the viewer has nothing to anchor on and it reads as a side-slide.
|
||||
|
||||
Usage:
|
||||
from orbit_module import run_orbit_pipeline
|
||||
result = run_orbit_pipeline(image_path, output_dir)
|
||||
|
||||
CLI: see orbit_poc.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
|
||||
__all__ = [
|
||||
"create_depth_map",
|
||||
"render_orbit_frame",
|
||||
"render_orbit",
|
||||
"save_orbit_output",
|
||||
"run_orbit_pipeline",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Image loading helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_rgba(path: str) -> np.ndarray:
|
||||
"""Load any image as RGBA uint8 H×W×4."""
|
||||
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Cannot read image: {path}")
|
||||
if img.ndim == 2:
|
||||
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGRA)
|
||||
elif img.shape[2] == 3:
|
||||
img = cv2.cvtColor(img, cv2.COLOR_BGR2BGRA)
|
||||
return cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
|
||||
|
||||
|
||||
def _has_real_alpha(rgba: np.ndarray) -> bool:
|
||||
"""True if the image contains meaningful transparency (not just all-255)."""
|
||||
alpha = rgba[:, :, 3]
|
||||
transparent_pct = float((alpha < 32).mean())
|
||||
return transparent_pct > 0.05 # >5% transparent pixels = real alpha
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background plate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_original_for_nobg(actor_path: str) -> str | None:
|
||||
"""
|
||||
Given a .nobg.png sidecar path, find the original opaque image.
|
||||
e.g. foo.nobg.png → foo.png or foo.jpg
|
||||
"""
|
||||
root, _ = os.path.splitext(actor_path)
|
||||
if not root.endswith(".nobg"):
|
||||
return None
|
||||
base = root[: -len(".nobg")]
|
||||
for ext in (".png", ".jpg", ".jpeg", ".webp"):
|
||||
p = base + ext
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _make_bg_plate(actor_rgba: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Build a static background plate for opaque images (no nobg available).
|
||||
|
||||
Strategy: blur the source image with a large kernel. The blurred copy stays
|
||||
fixed while the sharp actor layer shifts — creates subtle depth separation.
|
||||
"""
|
||||
H, W = actor_rgba.shape[:2]
|
||||
# Large blur: simulates out-of-focus background
|
||||
blurred_rgb = cv2.GaussianBlur(actor_rgba[:, :, :3], (0, 0), max(H, W) * 0.04)
|
||||
plate = np.dstack([blurred_rgb, np.full((H, W), 255, dtype=np.uint8)])
|
||||
return plate.astype(np.uint8)
|
||||
|
||||
|
||||
def get_bg_plate(actor_path: str, actor_rgba: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Return the background plate for the orbit:
|
||||
- .nobg.png: load the matching original opaque image (best result)
|
||||
- Opaque image: return blurred copy (subtle but functional)
|
||||
Plate is resized to match actor_rgba dimensions.
|
||||
"""
|
||||
H, W = actor_rgba.shape[:2]
|
||||
|
||||
orig = _find_original_for_nobg(actor_path)
|
||||
if orig:
|
||||
bg = _load_rgba(orig)
|
||||
if bg.shape[:2] != (H, W):
|
||||
bg = cv2.resize(bg, (W, H), interpolation=cv2.INTER_AREA)
|
||||
bg[:, :, 3] = 255 # ensure fully opaque background
|
||||
return bg
|
||||
|
||||
return _make_bg_plate(actor_rgba)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Depth map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_depth_map(image_rgba: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Float32 H×W depth in [0,1]. 1 = closest (subject centre), 0 = far/background.
|
||||
|
||||
Uses the alpha mask distance transform:
|
||||
- For transparent-bg images: EDT of the foreground mask (subject body)
|
||||
- For opaque images: EDT from image edges (assumes subject is centred)
|
||||
|
||||
Power-law shaping (^0.5) keeps the gradient gradual near the centre.
|
||||
"""
|
||||
alpha = image_rgba[:, :, 3]
|
||||
mask = (alpha > 32).astype(np.uint8)
|
||||
|
||||
if mask.sum() == 0:
|
||||
return np.zeros(alpha.shape, dtype=np.float32)
|
||||
|
||||
dist = distance_transform_edt(mask).astype(np.float32)
|
||||
max_d = dist.max()
|
||||
if max_d > 0:
|
||||
dist /= max_d
|
||||
return np.sqrt(dist)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orbit rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _alpha_composite(fg: np.ndarray, bg: np.ndarray) -> np.ndarray:
|
||||
"""Alpha-composite RGBA fg over RGBA bg. Returns RGBA uint8."""
|
||||
a = fg[:, :, 3:4].astype(np.float32) / 255.0
|
||||
out_rgb = fg[:, :, :3].astype(np.float32) * a + bg[:, :, :3].astype(np.float32) * (1.0 - a)
|
||||
out_a = fg[:, :, 3:4].astype(np.float32) + bg[:, :, 3:4].astype(np.float32) * (1.0 - a)
|
||||
return np.dstack([out_rgb.clip(0, 255), out_a.clip(0, 255)]).astype(np.uint8)
|
||||
|
||||
|
||||
def render_orbit_frame(
|
||||
actor_rgba: np.ndarray,
|
||||
depth: np.ndarray,
|
||||
theta: float,
|
||||
parallax_strength: float = 0.08,
|
||||
bg_rgba: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Swing-mode frame: depth-based parallax shift only.
|
||||
Closer pixels (depth≈1) shift more than far pixels (depth≈0).
|
||||
Composited over static bg plate for perceivable depth.
|
||||
Returns RGBA uint8 H×W×4.
|
||||
"""
|
||||
H, W = actor_rgba.shape[:2]
|
||||
|
||||
shift_x = depth * (W * parallax_strength * math.sin(theta))
|
||||
shift_y = depth * (H * parallax_strength * 0.03 * -math.cos(theta))
|
||||
|
||||
yc, xc = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
map_x = (xc - shift_x).astype(np.float32)
|
||||
map_y = (yc - shift_y).astype(np.float32)
|
||||
|
||||
bgra = cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA)
|
||||
warped_bgra = cv2.remap(bgra, map_x, map_y,
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT,
|
||||
borderValue=(0, 0, 0, 0))
|
||||
warped = cv2.cvtColor(warped_bgra, cv2.COLOR_BGRA2RGBA)
|
||||
|
||||
if bg_rgba is None:
|
||||
return warped
|
||||
return _alpha_composite(warped, bg_rgba)
|
||||
|
||||
|
||||
def _perspective_card_frame(
|
||||
bgra_src: np.ndarray,
|
||||
theta: float,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Orbit-mode frame: simulate a flat card rotating around its vertical axis.
|
||||
|
||||
- Squishes width by |cos(θ)|, centred on canvas
|
||||
- Mirrors source for the back half (cos < 0) so the "back" is visible
|
||||
- Returns BGRA with transparent borders (ready for bg composite)
|
||||
|
||||
At θ=0° → full width, unmirrored (front face)
|
||||
At θ=90° → hair-thin line
|
||||
At θ=180°→ full width, mirrored (back face)
|
||||
"""
|
||||
H, W = bgra_src.shape[:2]
|
||||
cos_t = math.cos(theta)
|
||||
compress = abs(cos_t)
|
||||
is_back = cos_t < 0
|
||||
|
||||
if compress < 0.025:
|
||||
# Near-edge-on: return a one-pixel-wide vertical strip to avoid singularity
|
||||
out = np.zeros_like(bgra_src)
|
||||
mid = W // 2
|
||||
out[:, mid:mid+1] = bgra_src[:, mid:mid+1]
|
||||
return out
|
||||
|
||||
new_w = max(int(round(W * compress)), 2)
|
||||
x0 = (W - new_w) // 2
|
||||
x1 = x0 + new_w
|
||||
|
||||
# Source corners: mirror left↔right for the back face
|
||||
if is_back:
|
||||
src = np.float32([[W, 0], [0, 0], [0, H], [W, H]])
|
||||
else:
|
||||
src = np.float32([[0, 0], [W, 0], [W, H], [0, H]])
|
||||
|
||||
dst = np.float32([[x0, 0], [x1, 0], [x1, H], [x0, H]])
|
||||
|
||||
M = cv2.getPerspectiveTransform(src, dst)
|
||||
return cv2.warpPerspective(bgra_src, M, (W, H),
|
||||
flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_CONSTANT,
|
||||
borderValue=(0, 0, 0, 0))
|
||||
|
||||
|
||||
def render_orbit(
|
||||
actor_rgba: np.ndarray,
|
||||
depth: np.ndarray,
|
||||
n_frames: int = 36,
|
||||
parallax_strength: float = 0.08,
|
||||
mode: str = "swing",
|
||||
max_angle_deg: float = 35.0,
|
||||
bg_rgba: np.ndarray | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
Render all orbit frames.
|
||||
|
||||
mode='swing' — sinusoidal ±max_angle_deg depth-parallax, loops cleanly
|
||||
mode='orbit' — full 360° perspective card rotation (compress + mirror)
|
||||
|
||||
Returns list of RGBA uint8 frames.
|
||||
"""
|
||||
if mode == "swing":
|
||||
max_rad = math.radians(max_angle_deg)
|
||||
angles = [max_rad * math.sin(2 * math.pi * i / n_frames) for i in range(n_frames)]
|
||||
return [render_orbit_frame(actor_rgba, depth, theta, parallax_strength, bg_rgba)
|
||||
for theta in angles]
|
||||
|
||||
elif mode == "orbit":
|
||||
# Don't use the photo bg plate — the transparent areas should stay transparent
|
||||
# so the perspective compression (card getting thin at 90°, mirrored at 180°)
|
||||
# is clearly visible. Solid bg is added at MP4 write time.
|
||||
angles = [2 * math.pi * i / n_frames for i in range(n_frames)]
|
||||
bgra_src = cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA)
|
||||
return [
|
||||
cv2.cvtColor(_perspective_card_frame(bgra_src, theta), cv2.COLOR_BGRA2RGBA)
|
||||
for theta in angles
|
||||
]
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown mode: {mode!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Output saving
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _composite_over_solid(frame_rgba: np.ndarray, bg: tuple = (18, 18, 18)) -> np.ndarray:
|
||||
"""Alpha-composite RGBA over a solid colour; return BGR uint8 for ffmpeg."""
|
||||
rgb = frame_rgba[:, :, :3].astype(np.float32)
|
||||
a = frame_rgba[:, :, 3:4].astype(np.float32) / 255.0
|
||||
bg_f = np.array(bg, dtype=np.float32)
|
||||
out = (rgb * a + bg_f * (1.0 - a)).clip(0, 255).astype(np.uint8)
|
||||
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
def save_orbit_output(
|
||||
frames: list,
|
||||
output_dir: str,
|
||||
fps: int = 24,
|
||||
bg_color: tuple = (18, 18, 18),
|
||||
) -> dict:
|
||||
"""
|
||||
Write orbit_frames/frame_NNN.png (RGBA) and orbit_preview.mp4.
|
||||
Returns dict with paths.
|
||||
"""
|
||||
frames_dir = os.path.join(output_dir, "orbit_frames")
|
||||
os.makedirs(frames_dir, exist_ok=True)
|
||||
|
||||
frame_paths = []
|
||||
for i, frame in enumerate(frames):
|
||||
path = os.path.join(frames_dir, f"frame_{i:03d}.png")
|
||||
cv2.imwrite(path, cv2.cvtColor(frame, cv2.COLOR_RGBA2BGRA))
|
||||
frame_paths.append(path)
|
||||
|
||||
video_path = os.path.join(output_dir, "orbit_preview.mp4")
|
||||
_frames_to_mp4(frames, video_path, fps=fps, bg_color=bg_color)
|
||||
|
||||
return {
|
||||
"frames_dir": frames_dir,
|
||||
"n_frames": len(frames),
|
||||
"video_path": video_path,
|
||||
"frame_paths": frame_paths,
|
||||
}
|
||||
|
||||
|
||||
def _frames_to_mp4(
|
||||
frames: list, output_path: str, fps: int = 24, bg_color: tuple = (18, 18, 18)
|
||||
) -> None:
|
||||
"""Composite frames over solid bg, write MP4 via ffmpeg."""
|
||||
if not frames:
|
||||
return
|
||||
with tempfile.TemporaryDirectory(prefix="orbit_mp4_") as tmpdir:
|
||||
for i, frame in enumerate(frames):
|
||||
bgr = _composite_over_solid(frame, bg_color)
|
||||
cv2.imwrite(
|
||||
os.path.join(tmpdir, f"frame_{i:04d}.jpg"), bgr,
|
||||
[cv2.IMWRITE_JPEG_QUALITY, 95],
|
||||
)
|
||||
H, W = frames[0].shape[:2]
|
||||
W2, H2 = W - (W % 2), H - (H % 2)
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(tmpdir, "frame_%04d.jpg"),
|
||||
"-vf", f"crop={W2}:{H2}:0:0",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-crf", "18", "-movflags", "+faststart",
|
||||
output_path,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {r.stderr[-600:]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Debug helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_debug(actor_rgba, actor_path, bg_rgba, debug_dir):
|
||||
os.makedirs(debug_dir, exist_ok=True)
|
||||
if os.path.exists(actor_path):
|
||||
shutil.copy2(actor_path, os.path.join(debug_dir, "selected_frame.png"))
|
||||
cv2.imwrite(os.path.join(debug_dir, "actor_rgba.png"),
|
||||
cv2.cvtColor(actor_rgba, cv2.COLOR_RGBA2BGRA))
|
||||
cv2.imwrite(os.path.join(debug_dir, "mask.png"), actor_rgba[:, :, 3])
|
||||
if bg_rgba is not None:
|
||||
cv2.imwrite(os.path.join(debug_dir, "bg_plate.png"),
|
||||
cv2.cvtColor(bg_rgba, cv2.COLOR_RGBA2BGRA))
|
||||
|
||||
|
||||
def _save_depth_debug(depth, debug_dir):
|
||||
os.makedirs(debug_dir, exist_ok=True)
|
||||
d8 = (depth * 255).astype(np.uint8)
|
||||
cv2.imwrite(os.path.join(debug_dir, "depth.png"), d8)
|
||||
cv2.imwrite(os.path.join(debug_dir, "depth_colorized.png"),
|
||||
cv2.applyColorMap(d8, cv2.COLORMAP_MAGMA))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_orbit_pipeline(
|
||||
image_path: str,
|
||||
output_dir: str,
|
||||
n_frames: int = 36,
|
||||
parallax_strength: float = 0.08,
|
||||
mode: str = "swing",
|
||||
fps: int = 24,
|
||||
max_angle_deg: float = 35.0,
|
||||
debug: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Full pipeline: load → bg-plate → depth → render → save.
|
||||
|
||||
image_path: the specific image to orbit (caller selects; no sharpness heuristic)
|
||||
Returns dict: actor_path, frames_dir, video_path, n_frames, debug_dir, has_alpha, has_bg
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
debug_dir = os.path.join(output_dir, "debug")
|
||||
|
||||
# 1. Load actor — prefer nobg sidecar for cleaner depth
|
||||
actor_path = image_path
|
||||
root, _ = os.path.splitext(image_path)
|
||||
nobg_candidate = root + ".nobg.png"
|
||||
if not root.endswith(".nobg") and os.path.exists(nobg_candidate):
|
||||
actor_path = nobg_candidate
|
||||
|
||||
actor_rgba = _load_rgba(actor_path)
|
||||
has_alpha = _has_real_alpha(actor_rgba)
|
||||
|
||||
# 2. Background plate (static reference — essential for perceivable depth)
|
||||
bg_rgba = get_bg_plate(actor_path, actor_rgba)
|
||||
has_bg = bg_rgba is not None
|
||||
|
||||
if debug:
|
||||
_save_debug(actor_rgba, actor_path, bg_rgba, debug_dir)
|
||||
|
||||
# 3. Depth map
|
||||
depth = create_depth_map(actor_rgba)
|
||||
|
||||
if debug:
|
||||
_save_depth_debug(depth, debug_dir)
|
||||
|
||||
# 4. Render
|
||||
frames = render_orbit(
|
||||
actor_rgba, depth,
|
||||
n_frames=n_frames,
|
||||
parallax_strength=parallax_strength,
|
||||
mode=mode,
|
||||
max_angle_deg=max_angle_deg,
|
||||
bg_rgba=bg_rgba,
|
||||
)
|
||||
|
||||
# 5. Save
|
||||
result = save_orbit_output(frames, output_dir, fps=fps)
|
||||
result.update({
|
||||
"actor_path": actor_path,
|
||||
"debug_dir": debug_dir,
|
||||
"has_alpha": has_alpha,
|
||||
"has_bg": has_bg,
|
||||
})
|
||||
return result
|
||||
141
tour-comfy/orbit_poc.py
Executable file
141
tour-comfy/orbit_poc.py
Executable file
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
orbit_poc.py — 2.5D actor orbit preview proof-of-concept.
|
||||
|
||||
Usage:
|
||||
python orbit_poc.py --input img1.png img2.png ... --output ./output
|
||||
python orbit_poc.py --input ./filmstrip_images/ --output ./output --mode swing --frames 36
|
||||
|
||||
Output:
|
||||
./output/orbit_frames/frame_NNN.png
|
||||
./output/orbit_preview.mp4
|
||||
./output/debug/selected_frame.png
|
||||
./output/debug/actor_rgba.png
|
||||
./output/debug/mask.png
|
||||
./output/debug/depth.png
|
||||
./output/debug/depth_colorized.png
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Ensure tour-comfy is on the path when running from project root
|
||||
_here = os.path.dirname(os.path.abspath(__file__))
|
||||
if _here not in sys.path:
|
||||
sys.path.insert(0, _here)
|
||||
|
||||
from orbit_module import run_orbit_pipeline
|
||||
|
||||
|
||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif"}
|
||||
|
||||
|
||||
def _collect_inputs(raw_inputs: list) -> list:
|
||||
"""Expand dirs and glob patterns; return sorted list of image paths."""
|
||||
paths = []
|
||||
for item in raw_inputs:
|
||||
if os.path.isdir(item):
|
||||
for fname in sorted(os.listdir(item)):
|
||||
if os.path.splitext(fname)[1].lower() in _IMAGE_EXTS:
|
||||
paths.append(os.path.join(item, fname))
|
||||
elif "*" in item or "?" in item:
|
||||
paths.extend(sorted(glob.glob(item)))
|
||||
elif os.path.isfile(item):
|
||||
paths.append(item)
|
||||
else:
|
||||
print(f"[warn] not found: {item}", file=sys.stderr)
|
||||
return paths
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate a 2.5D orbit preview (depth-card parallax) from actor images."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input", "-i", nargs="+", required=True,
|
||||
metavar="PATH",
|
||||
help="Input image paths, glob patterns, or directories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", "-o", default="./output",
|
||||
metavar="DIR",
|
||||
help="Output directory (default: ./output)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--frames", "-f", type=int, default=36,
|
||||
help="Number of animation frames (default: 36)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fps", type=int, default=24,
|
||||
help="Output video framerate (default: 24)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parallax", "-p", type=float, default=0.08,
|
||||
help="Parallax strength 0–1, fraction of image width (default: 0.08)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--angle", "-a", type=float, default=35.0,
|
||||
help="Maximum orbit angle in degrees for swing mode (default: 35)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode", "-m", choices=["swing", "orbit"], default="swing",
|
||||
help="'swing' = left↔right loop (default), 'orbit' = full 360°",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-debug", action="store_true",
|
||||
help="Skip writing debug output files",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
image_paths = _collect_inputs(args.input)
|
||||
if not image_paths:
|
||||
print("[error] No input images found.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[orbit] {len(image_paths)} input image(s)")
|
||||
for p in image_paths[:5]:
|
||||
print(f" {p}")
|
||||
if len(image_paths) > 5:
|
||||
print(f" ... ({len(image_paths) - 5} more)")
|
||||
|
||||
print(f"[orbit] output dir : {os.path.abspath(args.output)}")
|
||||
print(f"[orbit] frames={args.frames} fps={args.fps} mode={args.mode} "
|
||||
f"parallax={args.parallax} angle±{args.angle}°")
|
||||
|
||||
# Use the first image as the primary input (CLI can pass multiple; first = best choice)
|
||||
primary = image_paths[0]
|
||||
if len(image_paths) > 1:
|
||||
print(f"[orbit] using first image as primary: {primary}")
|
||||
print(f" (pass a single image or the specific frame you want)")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
result = run_orbit_pipeline(
|
||||
image_path=primary,
|
||||
output_dir=args.output,
|
||||
n_frames=args.frames,
|
||||
parallax_strength=args.parallax,
|
||||
mode=args.mode,
|
||||
fps=args.fps,
|
||||
max_angle_deg=args.angle,
|
||||
debug=not args.no_debug,
|
||||
)
|
||||
elapsed = time.perf_counter() - t0
|
||||
|
||||
print(f"\n[orbit] done in {elapsed:.1f}s")
|
||||
print(f" actor : {result['actor_path']}")
|
||||
print(f" has alpha : {result['has_alpha']}")
|
||||
print(f" has bg plate : {result['has_bg']}")
|
||||
print(f" frames dir : {result['frames_dir']} ({result['n_frames']} PNGs)")
|
||||
print(f" video : {result['video_path']}")
|
||||
if not args.no_debug:
|
||||
print(f" debug dir : {result['debug_dir']}")
|
||||
if not result['has_alpha']:
|
||||
print(f"\n TIP: Use 'No BG' on this image first for a much better orbit effect.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
679
tour-comfy/orbit_qwen.py
Normal file
679
tour-comfy/orbit_qwen.py
Normal file
@@ -0,0 +1,679 @@
|
||||
"""
|
||||
orbit_qwen.py — near-real actor turntable using Qwen-Image-Edit.
|
||||
|
||||
Unlike orbit_module.py (fake 2.5D depth-card parallax), this actually asks the
|
||||
generative model to RE-RENDER the subject at each yaw angle. Each view is
|
||||
anchored to the original front image with a fixed seed so identity, body, hair
|
||||
and lighting stay consistent while only the viewpoint rotates.
|
||||
|
||||
Pipeline:
|
||||
1. build a yaw-angle prompt per frame (turntable or swing)
|
||||
2. _run_pipeline (Qwen via ComfyUI) → one re-rendered view per angle
|
||||
3. bottom-center align onto a common canvas
|
||||
4. stitch to a looping MP4
|
||||
|
||||
Validated finding (2026-06-25): 2D blending between independently-generated
|
||||
views (optical-flow morph OR crossfade) always ghosts — the bodies don't
|
||||
overlap, so any in-between frame shows a double exposure. The cure is DENSITY,
|
||||
not blending: ~24 crisp keyframes (15° steps) played with NO interpolation at
|
||||
~12fps reads as a smooth turntable, exactly like classic 3D turntable GIFs.
|
||||
Interpolation is kept available (interp_factor>1) but defaults OFF.
|
||||
|
||||
Reuses edit_api._run_pipeline, so it talks to the same running ComfyUI server.
|
||||
|
||||
Usage:
|
||||
from orbit_qwen import run_qwen_orbit
|
||||
result = run_qwen_orbit("/path/to/front.png", "/out/dir", n_views=12)
|
||||
|
||||
CLI: see orbit_qwen_poc.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import sys
|
||||
import math
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Reuse the real Qwen pipeline from the API service (no server round-trip needed;
|
||||
# _run_pipeline queues directly to ComfyUI). Import is cheap — only loads the
|
||||
# workflow JSON; models load lazily and the uvicorn startup hook does not fire.
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from edit_api import _run_pipeline, _load_output_dir, MAX_AREA # noqa: E402
|
||||
|
||||
__all__ = [
|
||||
"is_front_view",
|
||||
"is_face_visible",
|
||||
"yaw_prompt",
|
||||
"generate_views",
|
||||
"interpolate_views",
|
||||
"build_video",
|
||||
"run_qwen_orbit",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Prompt construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_front_view(pil_image: Image.Image) -> bool:
|
||||
"""Detect if the image is a clear front view where nose and both eyes or ears are visible."""
|
||||
try:
|
||||
from edit_api import _load_pose_estimator
|
||||
estimator = _load_pose_estimator()
|
||||
if not estimator:
|
||||
return True
|
||||
infer_fn, _ = estimator
|
||||
people = infer_fn(pil_image)
|
||||
if not people:
|
||||
return True
|
||||
kpts = people[0]
|
||||
# kpts format: 17 joints, each is [x, y, score]
|
||||
# 0: nose, 1: left_eye, 2: right_eye, 3: left_ear, 4: right_ear
|
||||
nose_score = kpts[0][2]
|
||||
l_eye_score = kpts[1][2]
|
||||
r_eye_score = kpts[2][2]
|
||||
l_ear_score = kpts[3][2]
|
||||
r_ear_score = kpts[4][2]
|
||||
|
||||
# Symmetrical front view detection:
|
||||
if nose_score > 0.4:
|
||||
if l_eye_score > 0.4 and r_eye_score > 0.4:
|
||||
return True
|
||||
if l_ear_score > 0.4 and r_ear_score > 0.4:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[orbit-qwen] is_front_view check failed: {e}. Defaulting to True.")
|
||||
return True
|
||||
|
||||
|
||||
def is_face_visible(deg: float) -> bool:
|
||||
"""True if face/nose is visible at this yaw angle, False for rear views."""
|
||||
d = deg % 360
|
||||
return d <= 97.5 or d >= 262.5
|
||||
|
||||
|
||||
# Identity lock appended to every angle — keeps face/body/hair consistent across views.
|
||||
# For front/side views where the face is visible:
|
||||
_IDENTITY_FRONT = (
|
||||
"same person, identical face, identical hair style and color, identical body shape and proportions, "
|
||||
"same skin tone, same clothing, same lighting, photorealistic, sharp focus, "
|
||||
"full body visible head to toe, centered, transparent background "
|
||||
)
|
||||
|
||||
# For rear/back views where the face is hidden (omits "face" keyword to avoid contradiction/hallucination):
|
||||
_IDENTITY_BACK = (
|
||||
"same person, identical hair style and color from behind, identical body shape and proportions, "
|
||||
"same skin tone, same clothing, same lighting, photorealistic, sharp focus, "
|
||||
"full body visible head to toe from behind, centered, transparent background "
|
||||
)
|
||||
|
||||
|
||||
def _angle_phrase(deg: float) -> str:
|
||||
"""
|
||||
24 distinct buckets, each 15° wide, boundaries at 7.5°/22.5°/37.5°…352.5°.
|
||||
Works correctly for n_views=12 (30° steps) AND n_views=24 (15° steps).
|
||||
|
||||
Convention (confirmed by test):
|
||||
• 90° → face/nose points LEFT in the output image (camera to subject's right).
|
||||
• 270° → face/nose points RIGHT in the output image (camera to subject's left).
|
||||
• Rear views: viewed from behind, anatomical right appears on image LEFT.
|
||||
• Profile and rear-view anchors use explicit image-coordinate phrases to
|
||||
prevent Qwen from swapping sides.
|
||||
"""
|
||||
d = deg % 360
|
||||
|
||||
# ── front ──────────────────────────────────────────────────────────────────
|
||||
if d < 7.5 or d >= 352.5: # 0° — full front
|
||||
return (
|
||||
"showing her full front directly toward the camera: "
|
||||
"her face, both breasts, navel, and the fronts of both legs are fully visible, "
|
||||
"her back is completely hidden"
|
||||
)
|
||||
|
||||
# ── right-front quadrant ───────────────────────────────────────────────────
|
||||
elif d < 22.5: # 15° — barely perceptible right-front tilt
|
||||
return (
|
||||
"facing almost directly toward the camera — just the subtlest hint of a right-front turn. "
|
||||
"Both eyes and her full face are visible. "
|
||||
"In the output image her face is nearly perfectly centered, "
|
||||
"with only the tiniest tilt toward the LEFT edge. "
|
||||
"Her left shoulder is just a hair closer to the camera than her right. "
|
||||
"This looks almost identical to a pure front view"
|
||||
)
|
||||
elif d < 37.5: # 30° — slight right-front turn
|
||||
return (
|
||||
"turned slightly to her right — a subtle right-front view. "
|
||||
"Both eyes visible, face still mostly toward the camera. "
|
||||
"In the output image her face is nearly centered but noticeably shifted toward the LEFT side. "
|
||||
"Her left shoulder is clearly closer to the camera than her right"
|
||||
)
|
||||
elif d < 52.5: # 45° — gentle three-quarter right-front
|
||||
return (
|
||||
"turned about 45° to her right. "
|
||||
"Her face is partly toward the camera, left cheek and jaw more visible than right. "
|
||||
"In the output image her face appears on the LEFT half, nose angled toward the left edge. "
|
||||
"Her left shoulder, left breast and left hip are angled toward the camera. "
|
||||
"Her right side is starting to turn away"
|
||||
)
|
||||
elif d < 67.5: # 60° — clear three-quarter right-front
|
||||
return (
|
||||
"turned so the camera sees a clear three-quarter right-front view. "
|
||||
"In the output image her face is partially visible on the LEFT side, nose pointing left. "
|
||||
"Her left breast, left shoulder and left hip are angled toward the camera. "
|
||||
"Her right breast, right hip and right side are turned away from camera"
|
||||
)
|
||||
elif d < 82.5: # 75° — strong right-front, almost profile
|
||||
return (
|
||||
"turned strongly to her right — almost a pure side profile, but the face is still slightly visible. "
|
||||
"In the output image her face is on the LEFT side with nose pointing toward the left edge. "
|
||||
"Her left ear, left cheek and left shoulder are the main visible features. "
|
||||
"Her right breast and right side are mostly hidden"
|
||||
)
|
||||
|
||||
# ── right profile ──────────────────────────────────────────────────────────
|
||||
elif d < 97.5: # 90° — pure right profile
|
||||
return (
|
||||
"in a pure side profile. "
|
||||
"IMPORTANT: In the output image her nose and face point toward the LEFT edge of the frame — "
|
||||
"she is NOT facing right. "
|
||||
"Her chest and front of her body are on the LEFT side of the image; "
|
||||
"her back (spine, shoulder blade) is on the RIGHT side of the image. "
|
||||
"Her left side is facing the camera, and her right side is completely hidden behind her body"
|
||||
)
|
||||
|
||||
# ── right-rear quadrant ────────────────────────────────────────────────────
|
||||
elif d < 112.5: # 105° — just past right profile, back turning
|
||||
return (
|
||||
"turned just past a pure right-side profile — she is starting to show her back. "
|
||||
"THIS IS A BACK-TURNING VIEW: her back is starting to face the camera. "
|
||||
"Her spine is on the RIGHT side of the image. "
|
||||
"Her left shoulder blade (on the left half of the image) is becoming more visible. "
|
||||
"Her face is almost completely hidden — only the very edge of her profile is barely visible on the far left edge of the image. "
|
||||
"Her spine and left shoulder blade are the main features. Her right side is hidden"
|
||||
)
|
||||
elif d < 127.5: # 120° — three-quarter rear-right
|
||||
return (
|
||||
"THIS IS A BACK VIEW — her back faces the camera. "
|
||||
"Three-quarter rear-right: her left shoulder blade and left hip (on the left half of the image) are most prominent. "
|
||||
"Her spine is on the RIGHT half of the image. "
|
||||
"In the output image her left shoulder blade appears on the LEFT half of the image, "
|
||||
"with her back turning towards the camera. "
|
||||
"Her face is completely hidden. No breasts visible"
|
||||
)
|
||||
elif d < 142.5: # 135° — rear-right, heading toward full back
|
||||
return (
|
||||
"THIS IS A BACK VIEW — her back faces the camera. "
|
||||
"Rear-right view, closer to a full back than to a side profile. "
|
||||
"Her spine is on the RIGHT half of the image. "
|
||||
"Her left shoulder blade is somewhat LEFT of center in the image. "
|
||||
"Her right shoulder blade is also visible but less prominent. "
|
||||
"Face completely hidden. Buttocks and backs of legs visible"
|
||||
)
|
||||
elif d < 157.5: # 150° — mostly back, subtle right lean
|
||||
return (
|
||||
"THIS IS A BACK VIEW — her back faces the camera. "
|
||||
"Nearly a full back view with a very subtle lean. "
|
||||
"Her spine is slightly to the RIGHT of center in the image. "
|
||||
"Both shoulder blades are visible, with her left shoulder blade slightly more prominent. "
|
||||
#"Both shoulder blades are visible. "
|
||||
"Face completely hidden"
|
||||
)
|
||||
elif d < 172.5: # 165° — almost full back (right side)
|
||||
return (
|
||||
"THIS IS A BACK VIEW — almost exactly a full back view, the tiniest lean from the right. "
|
||||
"Her spine is just barely to the RIGHT of center in the image. "
|
||||
#"Both shoulder blades, buttocks and backs of both legs are visible. "
|
||||
"Her left shoulder blade is just barely more prominent. Face completely hidden"
|
||||
"Face completely hidden"
|
||||
)
|
||||
|
||||
# ── full back ──────────────────────────────────────────────────────────────
|
||||
elif d < 187.5: # 180° — pure full back
|
||||
return (
|
||||
"showing her full back to the camera: "
|
||||
"the back of her head, her spine, both shoulder blades equally, "
|
||||
"her buttocks, and the backs of both legs are fully visible. "
|
||||
"Her face and both breasts are completely hidden"
|
||||
)
|
||||
|
||||
# ── left-rear quadrant ─────────────────────────────────────────────────────
|
||||
elif d < 202.5: # 195° — almost full back (left side)
|
||||
return (
|
||||
"THIS IS A BACK VIEW — almost exactly a full back view, the tiniest lean from the left. "
|
||||
"Her spine is just barely to the LEFT of center in the image. "
|
||||
"Both shoulder blades, buttocks and backs of both legs are visible. "
|
||||
"Her right shoulder blade is just barely more prominent. Face completely hidden"
|
||||
)
|
||||
elif d < 217.5: # 210° — mostly back, subtle left lean
|
||||
return (
|
||||
"THIS IS A BACK VIEW — her back faces the camera. "
|
||||
"Nearly a full back view with a very subtle lean from the left side. "
|
||||
"Her spine is slightly to the LEFT of center in the image. "
|
||||
"Both shoulder blades are visible, with her right shoulder blade slightly more prominent. "
|
||||
#"Both shoulder blades are visible. "
|
||||
"Face completely hidden"
|
||||
)
|
||||
elif d < 232.5: # 225° — rear-left, heading toward full back
|
||||
return (
|
||||
"THIS IS A BACK VIEW — her back faces the camera. "
|
||||
"Rear-left view, closer to a full back than to a side profile. "
|
||||
"Her spine is on the LEFT half of the image. "
|
||||
"Her right shoulder blade is somewhat RIGHT of center in the image. "
|
||||
"Her left shoulder blade is also visible but less prominent. "
|
||||
"Face completely hidden. Buttocks and backs of legs visible"
|
||||
)
|
||||
elif d < 247.5: # 240° — three-quarter rear-left
|
||||
return (
|
||||
"THIS IS A BACK VIEW — her back faces the camera. "
|
||||
"Three-quarter rear-left: her right shoulder blade and right hip (on the right half of the image) are most prominent. "
|
||||
# "Three-quarter rear-left: her right hip (on the right half of the image) are most prominent. "
|
||||
"Her spine is on the LEFT half of the image. "
|
||||
"In the output image her right shoulder blade appears on the RIGHT half of the image, "
|
||||
"with her back turning towards the camera. "
|
||||
"Her face is completely hidden. No breasts visible"
|
||||
)
|
||||
elif d < 262.5: # 255° — just past left profile, back turning
|
||||
return (
|
||||
"turned just past a pure left-side profile — she is starting to show her back. "
|
||||
"THIS IS A BACK-TURNING VIEW: her back is starting to face the camera. "
|
||||
"Her spine is on the LEFT side of the image. "
|
||||
"Her right shoulder blade is becoming visible. "
|
||||
"Her face is almost completely hidden — only the very edge of her profile is barely visible on the far right edge of the image. "
|
||||
"Her spine and right shoulder blade are the main features. Her left side is hidden"
|
||||
)
|
||||
|
||||
# ── left profile ───────────────────────────────────────────────────────────
|
||||
elif d < 277.5: # 270° — pure left profile
|
||||
return (
|
||||
"in a pure side profile. "
|
||||
"IMPORTANT: In the output image her nose and face point toward the RIGHT edge of the frame — "
|
||||
"she is NOT facing left. "
|
||||
"Her chest and front of her body are on the RIGHT side of the image; "
|
||||
"her back (spine, shoulder blade) is on the LEFT side of the image. "
|
||||
"Her right side is facing the camera, and her left side is completely hidden behind her body"
|
||||
)
|
||||
|
||||
# ── left-front quadrant ────────────────────────────────────────────────────
|
||||
elif d < 292.5: # 285° — strong left-front, almost profile
|
||||
return (
|
||||
"turned strongly to her left — almost a pure side profile, but the face is still slightly visible. "
|
||||
"In the output image her face is on the RIGHT side with nose pointing toward the right edge. "
|
||||
"Her right ear, right cheek and right shoulder are the main visible features. "
|
||||
"Her left breast and left side are mostly hidden"
|
||||
)
|
||||
elif d < 307.5: # 300° — clear three-quarter left-front
|
||||
return (
|
||||
"turned so the camera sees a clear three-quarter left-front view. "
|
||||
"In the output image her face is partially visible on the RIGHT side, nose pointing right. "
|
||||
"Her right breast, right shoulder and right hip are angled toward the camera. "
|
||||
"Her left breast, left hip and left side are turned away from camera"
|
||||
)
|
||||
elif d < 322.5: # 315° — gentle three-quarter left-front
|
||||
return (
|
||||
"turned about 45° to her left. "
|
||||
"Her face is partly toward the camera, right cheek and jaw more visible than left. "
|
||||
"In the output image her face appears on the RIGHT half, nose angled toward the right edge. "
|
||||
"Her right shoulder, right breast and right hip are angled toward the camera. "
|
||||
"Her left side is starting to turn away"
|
||||
)
|
||||
elif d < 337.5: # 330° — slight left-front turn
|
||||
return (
|
||||
"turned slightly to her left — a subtle left-front view. "
|
||||
"Both eyes visible, face still mostly toward the camera. "
|
||||
"In the output image her face is nearly centered but noticeably shifted toward the RIGHT side. "
|
||||
"Her right shoulder is clearly closer to the camera than her left"
|
||||
)
|
||||
else: # 345° — barely perceptible left-front tilt
|
||||
return (
|
||||
"facing almost directly toward the camera — just the subtlest hint of a left-front turn. "
|
||||
"Both eyes and her full face are visible. "
|
||||
"In the output image her face is nearly perfectly centered, "
|
||||
"with only the tiniest tilt toward the RIGHT edge. "
|
||||
"Her right shoulder is just a hair closer to the camera than her left. "
|
||||
"This looks almost identical to a pure front view"
|
||||
)
|
||||
|
||||
|
||||
def yaw_prompt(deg: float) -> str:
|
||||
"""Full prompt for one turntable angle."""
|
||||
view = _angle_phrase(deg)
|
||||
identity = _IDENTITY_FRONT if is_face_visible(deg) else _IDENTITY_BACK
|
||||
return (
|
||||
f"Redraw this person {view}. "
|
||||
f"Keep everything identical — same person, same hair, same body, same lighting — "
|
||||
f"only the camera viewing angle changes. {identity}."
|
||||
)
|
||||
|
||||
|
||||
def _angles_for(mode: str, n_views: int, sweep_deg: float) -> list:
|
||||
"""Return the list of yaw angles to render."""
|
||||
if mode == "turntable":
|
||||
# Full 360, evenly spaced, loops cleanly
|
||||
return [360.0 * i / n_views for i in range(n_views)]
|
||||
elif mode == "swing":
|
||||
# -sweep/2 .. +sweep/2 .. back (front-facing arc only — most reliable)
|
||||
half = sweep_deg / 2.0
|
||||
fwd = [(-half + sweep_deg * i / (n_views - 1)) for i in range(n_views)]
|
||||
# map negatives into 0..360 turntable space (e.g. -45 -> 315)
|
||||
return [a % 360 for a in fwd]
|
||||
raise ValueError(f"Unknown mode: {mode!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. View generation (Qwen)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _autocrop_alpha(pil: Image.Image, pad: int = 8) -> Image.Image:
|
||||
"""Crop to the alpha bounding box (+pad) so every view is framed on the body."""
|
||||
if pil.mode != "RGBA":
|
||||
return pil
|
||||
alpha = np.array(pil)[:, :, 3]
|
||||
ys, xs = np.where(alpha > 16)
|
||||
if len(xs) == 0:
|
||||
return pil
|
||||
x0, x1 = max(0, xs.min() - pad), min(pil.width, xs.max() + pad)
|
||||
y0, y1 = max(0, ys.min() - pad), min(pil.height, ys.max() + pad)
|
||||
return pil.crop((x0, y0, x1, y1))
|
||||
|
||||
|
||||
def generate_views(
|
||||
image_path: str,
|
||||
output_dir: str,
|
||||
n_views: int = 12,
|
||||
seed: int = 42,
|
||||
mode: str = "turntable",
|
||||
sweep_deg: float = 180.0,
|
||||
anchor: str = "original",
|
||||
max_area: int = 0,
|
||||
steps: int = 8,
|
||||
on_progress=None,
|
||||
) -> list:
|
||||
"""
|
||||
Render one Qwen view per yaw angle.
|
||||
|
||||
anchor='original' — every view edits the SAME front image (stable identity)
|
||||
anchor='chain' — each view edits the previous result (smoother transitions,
|
||||
but identity can drift over a full turn)
|
||||
|
||||
Returns list of dicts: {deg, path, pil}.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
views_dir = os.path.join(output_dir, "views")
|
||||
os.makedirs(views_dir, exist_ok=True)
|
||||
|
||||
start_pil = Image.open(image_path).convert("RGB")
|
||||
is_front = is_front_view(start_pil)
|
||||
|
||||
if not is_front:
|
||||
print(f"[orbit-qwen] Input image is NOT a representative front view. Generating a full front-view first...")
|
||||
front_png = _run_pipeline(
|
||||
start_pil, yaw_prompt(0.0), seed,
|
||||
max_area or MAX_AREA,
|
||||
steps=steps
|
||||
)
|
||||
base_pil = Image.open(io.BytesIO(front_png)).convert("RGB")
|
||||
else:
|
||||
base_pil = start_pil
|
||||
|
||||
angles = _angles_for(mode, n_views, sweep_deg)
|
||||
|
||||
results = []
|
||||
prev_pil = None
|
||||
completed_views_uncropped: dict[float, Image.Image] = {} # deg -> uncropped RGBA pil
|
||||
for i, deg in enumerate(angles):
|
||||
# If we pre-generated the front view and this is the 0° view, use it directly!
|
||||
if not is_front and abs(deg) < 1e-3:
|
||||
view_pil = base_pil.convert("RGBA")
|
||||
completed_views_uncropped[deg] = view_pil
|
||||
|
||||
cropped_pil = _autocrop_alpha(view_pil)
|
||||
path = os.path.join(views_dir, f"view_{i:03d}_{int(deg):03d}deg.png")
|
||||
cropped_pil.save(path)
|
||||
results.append({"deg": deg, "path": path, "pil": cropped_pil})
|
||||
|
||||
if anchor == "chain":
|
||||
prev_pil = base_pil
|
||||
continue
|
||||
|
||||
# Hybrid anchor strategy:
|
||||
# Front/side views use the original front view.
|
||||
# Back/rear views use the immediately preceding completed view.
|
||||
if anchor == "chain":
|
||||
src_pil = prev_pil if prev_pil is not None else base_pil
|
||||
else:
|
||||
# "original" anchor, but with our hybrid back-view chain:
|
||||
if not is_face_visible(deg) and i > 0:
|
||||
prev_angle = angles[i - 1]
|
||||
src_pil = completed_views_uncropped[prev_angle].convert("RGB")
|
||||
else:
|
||||
src_pil = base_pil
|
||||
|
||||
prompt = yaw_prompt(deg)
|
||||
if on_progress:
|
||||
on_progress(i, len(angles), deg)
|
||||
|
||||
# Pass up to 2 already-generated views as extra references so Qwen can
|
||||
# maintain identity/hair/clothing consistency across the full rotation.
|
||||
extra_refs = None
|
||||
if completed_views_uncropped:
|
||||
def _angular_dist(a, b):
|
||||
d = abs(a - b) % 360
|
||||
return min(d, 360 - d)
|
||||
target_visible = is_face_visible(deg)
|
||||
eligible_views = {
|
||||
a: pil for a, pil in completed_views_uncropped.items()
|
||||
if is_face_visible(a) == target_visible
|
||||
}
|
||||
if eligible_views:
|
||||
sorted_done = sorted(eligible_views.keys(),
|
||||
key=lambda a: _angular_dist(a, deg))
|
||||
extra_refs = [eligible_views[a].convert("RGB") for a in sorted_done[:2]]
|
||||
|
||||
png = _run_pipeline(
|
||||
src_pil, prompt, seed,
|
||||
max_area or MAX_AREA,
|
||||
steps=steps,
|
||||
extra_images=extra_refs,
|
||||
)
|
||||
view_pil = Image.open(io.BytesIO(png)).convert("RGBA")
|
||||
completed_views_uncropped[deg] = view_pil
|
||||
|
||||
cropped_pil = _autocrop_alpha(view_pil)
|
||||
path = os.path.join(views_dir, f"view_{i:03d}_{int(deg):03d}deg.png")
|
||||
cropped_pil.save(path)
|
||||
results.append({"deg": deg, "path": path, "pil": cropped_pil})
|
||||
|
||||
if anchor == "chain":
|
||||
# Feed an RGB version forward (pipeline wants RGB anyway)
|
||||
prev_pil = view_pil.convert("RGB")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Smoothing — canvas-align + optical-flow interpolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _to_common_canvas(views: list, pad_frac: float = 0.12) -> list:
|
||||
"""
|
||||
Place every view on one fixed-size RGBA canvas, bottom-centered (feet anchored),
|
||||
so the body doesn't jump frame-to-frame. Returns list of HxWx4 uint8 arrays.
|
||||
"""
|
||||
H = max(v["pil"].height for v in views)
|
||||
W = max(v["pil"].width for v in views)
|
||||
padH, padW = int(H * pad_frac), int(W * pad_frac)
|
||||
CH, CW = H + 2 * padH, W + 2 * padW
|
||||
|
||||
out = []
|
||||
for v in views:
|
||||
p = v["pil"]
|
||||
canvas = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
|
||||
# bottom-centered: feet sit on a common baseline
|
||||
x = (CW - p.width) // 2
|
||||
y = CH - padH - p.height
|
||||
canvas.paste(p, (x, y), p)
|
||||
out.append(np.array(canvas))
|
||||
return out
|
||||
|
||||
|
||||
def _flow_morph_rgb(a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray:
|
||||
"""
|
||||
Optical-flow morph between two SOLID RGB frames (3-channel) at fraction t.
|
||||
Operates on composited-over-bg images so there is no alpha halo/ghost.
|
||||
Warps a→mid and b→mid, then blends.
|
||||
"""
|
||||
ag = cv2.cvtColor(a, cv2.COLOR_RGB2GRAY)
|
||||
bg = cv2.cvtColor(b, cv2.COLOR_RGB2GRAY)
|
||||
flow_ab = cv2.calcOpticalFlowFarneback(ag, bg, None, 0.5, 5, 31, 5, 7, 1.5, 0)
|
||||
flow_ba = cv2.calcOpticalFlowFarneback(bg, ag, None, 0.5, 5, 31, 5, 7, 1.5, 0)
|
||||
|
||||
H, W = ag.shape
|
||||
yc, xc = np.mgrid[0:H, 0:W].astype(np.float32)
|
||||
wa = cv2.remap(a, (xc + flow_ab[..., 0] * t), (yc + flow_ab[..., 1] * t),
|
||||
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
||||
wb = cv2.remap(b, (xc + flow_ba[..., 0] * (1 - t)), (yc + flow_ba[..., 1] * (1 - t)),
|
||||
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
||||
return (wa.astype(np.float32) * (1 - t) + wb.astype(np.float32) * t).clip(0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def interpolate_views(
|
||||
views: list,
|
||||
factor: int = 4,
|
||||
loop: bool = True,
|
||||
smooth: bool = True,
|
||||
bg: tuple = (18, 18, 18),
|
||||
) -> list:
|
||||
"""
|
||||
Expand keyframes into a smooth sequence.
|
||||
|
||||
Keyframes are first composited over the solid bg, so all blending happens
|
||||
in opaque RGB space — this removes the transparent-alpha ghosting that
|
||||
plagued earlier flow morphs.
|
||||
|
||||
factor — intermediate frames per keyframe pair (1 = keyframes only)
|
||||
loop — also blend last→first (seamless turntable)
|
||||
smooth — optical-flow morph (True) vs simple crossfade (False)
|
||||
|
||||
Returns list of HxWx3 uint8 RGB frames.
|
||||
"""
|
||||
canvases = _to_common_canvas(views)
|
||||
bg_arr = np.array(bg, dtype=np.float32)
|
||||
|
||||
def _flatten(rgba):
|
||||
a = rgba[:, :, 3:4].astype(np.float32) / 255.0
|
||||
return (rgba[:, :, :3].astype(np.float32) * a + bg_arr * (1 - a)).clip(0, 255).astype(np.uint8)
|
||||
|
||||
solid = [_flatten(c) for c in canvases]
|
||||
if factor <= 1:
|
||||
return solid
|
||||
|
||||
n = len(solid)
|
||||
pairs = n if loop else n - 1
|
||||
frames = []
|
||||
for i in range(pairs):
|
||||
a, b = solid[i], solid[(i + 1) % n]
|
||||
frames.append(a)
|
||||
for k in range(1, factor):
|
||||
t = k / factor
|
||||
if smooth:
|
||||
frames.append(_flow_morph_rgb(a, b, t))
|
||||
else:
|
||||
frames.append((a.astype(np.float32) * (1 - t) +
|
||||
b.astype(np.float32) * t).astype(np.uint8))
|
||||
if not loop:
|
||||
frames.append(solid[-1])
|
||||
return frames
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Video
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _composite_solid(frame: np.ndarray, bg=(18, 18, 18)) -> np.ndarray:
|
||||
"""Accept RGB (already flattened) or RGBA; return BGR for ffmpeg."""
|
||||
if frame.shape[2] == 3:
|
||||
return cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
|
||||
rgb = frame[:, :, :3].astype(np.float32)
|
||||
a = frame[:, :, 3:4].astype(np.float32) / 255.0
|
||||
bg_f = np.array(bg, dtype=np.float32)
|
||||
out = (rgb * a + bg_f * (1 - a)).clip(0, 255).astype(np.uint8)
|
||||
return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
|
||||
|
||||
|
||||
def build_video(frames: list, output_path: str, fps: int = 24, bg=(18, 18, 18)) -> None:
|
||||
if not frames:
|
||||
return
|
||||
with tempfile.TemporaryDirectory(prefix="orbit_qwen_") as tmp:
|
||||
for i, fr in enumerate(frames):
|
||||
cv2.imwrite(os.path.join(tmp, f"f_{i:04d}.jpg"),
|
||||
_composite_solid(fr, bg), [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
H, W = frames[0].shape[:2]
|
||||
W2, H2 = W - (W % 2), H - (H % 2)
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-framerate", str(fps),
|
||||
"-i", os.path.join(tmp, "f_%04d.jpg"),
|
||||
"-vf", f"crop={W2}:{H2}:0:0",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p",
|
||||
"-crf", "18", "-movflags", "+faststart", output_path,
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed: {r.stderr[-600:]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_qwen_orbit(
|
||||
image_path: str,
|
||||
output_dir: str,
|
||||
n_views: int = 24,
|
||||
seed: int = 42,
|
||||
mode: str = "turntable",
|
||||
sweep_deg: float = 180.0,
|
||||
anchor: str = "original",
|
||||
interp_factor: int = 1,
|
||||
smooth: bool = False,
|
||||
fps: int = 12,
|
||||
max_area: int = 0,
|
||||
steps: int = 8,
|
||||
on_progress=None,
|
||||
) -> dict:
|
||||
"""
|
||||
Full near-real turntable: generate Qwen views → align → MP4.
|
||||
|
||||
Defaults reflect the validated recipe: 24 crisp keyframes, NO blending,
|
||||
12fps. Raise interp_factor only if you accept morph ghosting.
|
||||
|
||||
Returns dict: views (list), n_views, n_frames, video_path, views_dir.
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
views = generate_views(
|
||||
image_path, output_dir,
|
||||
n_views=n_views, seed=seed, mode=mode, sweep_deg=sweep_deg,
|
||||
anchor=anchor, max_area=max_area, steps=steps, on_progress=on_progress,
|
||||
)
|
||||
|
||||
loop = (mode == "turntable")
|
||||
frames = interpolate_views(views, factor=interp_factor, loop=loop, smooth=smooth)
|
||||
|
||||
video_path = "" # MP4 not wanted, custom frame-loop used instead
|
||||
|
||||
return {
|
||||
"views": [{"deg": v["deg"], "path": v["path"]} for v in views],
|
||||
"n_views": len(views),
|
||||
"n_frames": len(frames),
|
||||
"video_path": "",
|
||||
"views_dir": os.path.join(output_dir, "views"),
|
||||
}
|
||||
71
tour-comfy/orbit_qwen_poc.py
Normal file
71
tour-comfy/orbit_qwen_poc.py
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
orbit_qwen_poc.py — near-real turntable test via Qwen-Image-Edit.
|
||||
|
||||
python orbit_qwen_poc.py --input front.png --output ./out \
|
||||
--views 12 --mode turntable --interp 4 --seed 42
|
||||
|
||||
Generates one re-rendered view per yaw angle (anchored to the input, fixed seed),
|
||||
flow-interpolates for smoothness, and stitches a looping MP4.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from orbit_qwen import run_qwen_orbit
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Qwen turntable (near-real subject turning).")
|
||||
ap.add_argument("--input", "-i", required=True, help="Front-facing source image")
|
||||
ap.add_argument("--output", "-o", default="./out_turntable", help="Output directory")
|
||||
ap.add_argument("--views", "-v", type=int, default=24, help="Qwen keyframes (yaw steps)")
|
||||
ap.add_argument("--mode", "-m", choices=["turntable", "swing"], default="turntable",
|
||||
help="turntable=full 360 loop, swing=front-facing arc only")
|
||||
ap.add_argument("--sweep", type=float, default=180.0, help="swing arc width in degrees")
|
||||
ap.add_argument("--anchor", choices=["original", "chain"], default="original",
|
||||
help="original=each view from source (stable), chain=from previous (smoother)")
|
||||
ap.add_argument("--interp", type=int, default=1,
|
||||
help="interpolated frames per keyframe gap (1=none; >1 ghosts, not advised)")
|
||||
ap.add_argument("--no-smooth", action="store_true", help="crossfade instead of optical-flow morph")
|
||||
ap.add_argument("--fps", type=int, default=12)
|
||||
ap.add_argument("--seed", type=int, default=42)
|
||||
ap.add_argument("--steps", type=int, default=8, help="Qwen sampler steps (4 fast, 8 nicer)")
|
||||
ap.add_argument("--max-area", type=int, default=0, help="output pixel budget (0=API default)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(args.input):
|
||||
print(f"[error] input not found: {args.input}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def prog(i, n, deg):
|
||||
print(f" [{i+1}/{n}] rendering {int(deg):3d}°…", flush=True)
|
||||
|
||||
print(f"[turntable] input : {args.input}")
|
||||
print(f"[turntable] mode={args.mode} views={args.views} anchor={args.anchor} "
|
||||
f"interp×{args.interp} seed={args.seed} steps={args.steps}")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
res = run_qwen_orbit(
|
||||
image_path=args.input, output_dir=args.output,
|
||||
n_views=args.views, seed=args.seed, mode=args.mode, sweep_deg=args.sweep,
|
||||
anchor=args.anchor, interp_factor=args.interp, smooth=not args.no_smooth,
|
||||
fps=args.fps, max_area=args.max_area, steps=args.steps, on_progress=prog,
|
||||
)
|
||||
dt = time.perf_counter() - t0
|
||||
|
||||
print(f"\n[turntable] done in {dt:.1f}s "
|
||||
f"({dt/max(1,res['n_views']):.1f}s/view)")
|
||||
print(f" keyframes : {res['n_views']} → {res['views_dir']}")
|
||||
print(f" frames : {res['n_frames']} (after interpolation)")
|
||||
print(f" video : {res['video_path']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1686
tour-comfy/poses.md
1686
tour-comfy/poses.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -1,18 +0,0 @@
|
||||
[Unit]
|
||||
Description=Qwen-Image-Edit FastAPI Service
|
||||
After=comfyui-backend.service
|
||||
Requires=comfyui-backend.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__USER__
|
||||
Group=__GROUP__
|
||||
WorkingDirectory=__BASE__/api
|
||||
ExecStart=/bin/bash __BASE__/api/start_api.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=ComfyUI Backend for Qwen-Image-Edit
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=__USER__
|
||||
Group=__GROUP__
|
||||
WorkingDirectory=__BASE__
|
||||
ExecStart=/bin/bash __BASE__/api/run_comfyui.sh
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
233
tour-comfy/test/test_scenery_case.py
Normal file
233
tour-comfy/test/test_scenery_case.py
Normal 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())
|
||||
47
tour-comfy/test_orbit_12.py
Normal file
47
tour-comfy/test_orbit_12.py
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to generate a draft orbit of 12 images.
|
||||
Allows us to verify prompts and consistency across left and right sides.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
|
||||
from orbit_qwen import run_qwen_orbit
|
||||
|
||||
|
||||
def main():
|
||||
# "/mnt/zim/tour-comfy/output/20260625_045029_pose_3_20260618_173728_image.png"
|
||||
input_image = "/mnt/zim/tour-comfy/output/20260625_045029_pose_3_20260618_173728_image.png"
|
||||
output_dir = "/home/mike/dev/qwen-image-edit-rapid-aio-nsfw-v23/test/orbit_360_test_13"
|
||||
|
||||
if not os.path.exists(input_image):
|
||||
print(f"Error: input image not found: {input_image}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Generating 12-view orbit for: {input_image}")
|
||||
print(f"Output directory: {output_dir}")
|
||||
|
||||
t0 = time.perf_counter()
|
||||
res = run_qwen_orbit(
|
||||
image_path=input_image,
|
||||
output_dir=output_dir,
|
||||
n_views=12,
|
||||
seed=42,
|
||||
mode="turntable",
|
||||
anchor="original",
|
||||
interp_factor=1,
|
||||
steps=4, # Fast draft steps (4)
|
||||
on_progress=lambda i, n, deg: print(f" [{i + 1}/{n}] rendering {int(deg):3d}°...", flush=True)
|
||||
)
|
||||
dt = time.perf_counter() - t0
|
||||
print(f"Done in {dt:.1f}s")
|
||||
print(f"Views generated at: {res['views_dir']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1216
tour-comfy/test_regression_api.py
Normal file
1216
tour-comfy/test_regression_api.py
Normal file
File diff suppressed because it is too large
Load Diff
114
tour-comfy/test_regression_qwen.py
Normal file
114
tour-comfy/test_regression_qwen.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Ensure tour-comfy is in the import path
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.append(_HERE)
|
||||
|
||||
from edit_api import app, _load_output_dir
|
||||
|
||||
class TestQwenBackendRegression(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.client = TestClient(app)
|
||||
cls.output_dir = _load_output_dir()
|
||||
|
||||
# Real-data filenames specified in the issue description
|
||||
cls.real_img_1 = "20260618_053519_1_20260618_053458_image.png"
|
||||
cls.real_img_2 = "20260618_052537_0_20260618_052526_image.png"
|
||||
cls.wireframe_img = "up_20260628_104641_image.png"
|
||||
|
||||
# Validate that they exist on disk to run real end-to-end tests
|
||||
cls.run_real_tests = True
|
||||
img1_path = os.path.join(cls.output_dir, cls.real_img_1)
|
||||
img2_path = os.path.join(cls.output_dir, cls.real_img_2)
|
||||
|
||||
if not os.path.exists(img1_path) or not os.path.exists(img2_path):
|
||||
cls.run_real_tests = False
|
||||
print(f"[test] Real images not found at {img1_path} or {img2_path}. Running tests with fallback/mock assertion modes.")
|
||||
|
||||
def test_multi_ref_qwen_endpoint(self):
|
||||
"""Test multi-reference generation endpoint with specified real-data images."""
|
||||
if not self.run_real_tests:
|
||||
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
|
||||
|
||||
payload = {
|
||||
"filenames": [self.real_img_1, self.real_img_2],
|
||||
"prompt": "standing girl looking at camera, high quality, highly detailed",
|
||||
"seed": 42,
|
||||
"max_area": 512 * 512,
|
||||
"pad_outpaint": False
|
||||
}
|
||||
|
||||
response = self.client.post("/multi-ref", json=payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIn("job_id", data)
|
||||
job_id = data["job_id"]
|
||||
|
||||
# Poll status until done (limit to 300 seconds to prevent hanging if backend is offline)
|
||||
print(f"[test] Polling multi-ref job {job_id} until completion...")
|
||||
start_time = time.time()
|
||||
completed = False
|
||||
|
||||
while time.time() - start_time < 300:
|
||||
status_resp = self.client.get(f"/batch/{job_id}")
|
||||
if status_resp.status_code == 200:
|
||||
job_data = status_resp.json()
|
||||
status = job_data.get("status")
|
||||
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
|
||||
if status == "done":
|
||||
completed = True
|
||||
break
|
||||
elif status in ["error", "cancelled"]:
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
self.assertTrue(completed, "Multi-ref generation job did not complete successfully in time.")
|
||||
|
||||
def test_scenery_qwen_endpoint(self):
|
||||
"""Test scenery generation endpoint with specified wireframe scene image and prompt."""
|
||||
if not self.run_real_tests:
|
||||
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
|
||||
|
||||
payload = {
|
||||
"model_filename": self.real_img_1,
|
||||
"scene_image": self.wireframe_img,
|
||||
"prompt": "replace person from image 1 naturally with the person from Image 2. Keep the exact position of Image 1, and the exact person of Image 2",
|
||||
"seed": 42
|
||||
}
|
||||
|
||||
response = self.client.post("/generate-scenery", json=payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIn("job_id", data)
|
||||
job_id = data["job_id"]
|
||||
|
||||
# Poll status until done
|
||||
print(f"[test] Polling scenery job {job_id} until completion...")
|
||||
start_time = time.time()
|
||||
completed = False
|
||||
|
||||
while time.time() - start_time < 300:
|
||||
status_resp = self.client.get(f"/batch/{job_id}")
|
||||
if status_resp.status_code == 200:
|
||||
job_data = status_resp.json()
|
||||
status = job_data.get("status")
|
||||
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
|
||||
if status == "done":
|
||||
completed = True
|
||||
break
|
||||
elif status in ["error", "cancelled"]:
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
self.assertTrue(completed, "Scenery generation job did not complete successfully in time.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
286
tour-comfy/trash.html
Normal file
286
tour-comfy/trash.html
Normal file
@@ -0,0 +1,286 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Qwen Rapid-AIO — Database Consistency</title>
|
||||
<style>
|
||||
body {
|
||||
background: #111;
|
||||
color: #ccc;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
h1, h2 { color: #eee; }
|
||||
.container { max-width: 1000px; margin: 0 auto; }
|
||||
.section {
|
||||
background: #1a1a1a;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
.item:last-child { border-bottom: none; }
|
||||
.filename { font-family: monospace; color: #aaa; }
|
||||
.info { font-size: 0.9em; color: #777; }
|
||||
.btn {
|
||||
background: #333;
|
||||
color: #eee;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.btn:hover { background: #444; }
|
||||
.btn.danger { background: #633; }
|
||||
.btn.danger:hover { background: #844; }
|
||||
.btn.success { background: #363; }
|
||||
.btn.success:hover { background: #484; }
|
||||
.btn.primary { background: #35a; font-weight: bold; }
|
||||
.btn.primary:hover { background: #46b; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { color: #58a; text-decoration: none; font-size: 0.9em; }
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="nav">
|
||||
<a href="car.html">← Back to Studio</a>
|
||||
</div>
|
||||
<div class="header">
|
||||
<h1>Database Consistency</h1>
|
||||
<button class="btn primary" onclick="loadInconsistencies(true)">Run Check Now</button>
|
||||
</div>
|
||||
|
||||
<div id="loading" style="display:none">Running consistency check...</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Archived Items <span class="info">(Manually archived, recoverable)</span></h2>
|
||||
<div id="archivedList">No archived items found.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Missing Group IDs <span class="info">(Legacy or orphaned images with no group assigned)</span></h2>
|
||||
<div id="orphansList">No images with missing groups found.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Orphaned Records <span class="info">(In DB, but file missing on disk)</span></h2>
|
||||
<div id="missingList">No orphaned records found.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Untracked Files <span class="info">(On disk, but not in DB)</span></h2>
|
||||
<div id="untrackedList">No untracked files found.</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Metadata & Pose Deep Backfill <span class="info">(Re-analyze pose, anatomical completeness, gaze, and objects with detailed geometry rules)</span></h2>
|
||||
<div style="display:flex;gap:12px;margin-top:10px">
|
||||
<button class="btn danger" onclick="invalidateMetadata()">Invalidate All Old Metadata</button>
|
||||
<button class="btn success" onclick="triggerBackfill()">Trigger Deep Backfill Now</button>
|
||||
</div>
|
||||
<div id="backfillStatus" style="margin-top:12px;font-size:0.9em;color:#aaa"></div>
|
||||
</div>
|
||||
|
||||
<div class="info" id="timestamp"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
let API = window.location.origin;
|
||||
if (API === 'null' || API.startsWith('file://')) {
|
||||
API = 'http://127.0.0.1:8500';
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.style.display = 'block';
|
||||
setTimeout(() => t.style.display = 'none', 3000);
|
||||
}
|
||||
|
||||
async function repair(filename, action) {
|
||||
try {
|
||||
const r = await fetch(`${API}/db/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, action })
|
||||
});
|
||||
const d = await r.json();
|
||||
let msg = 'Done';
|
||||
if (d.status === 'deleted') msg = 'Record deleted';
|
||||
else if (d.status === 'imported') msg = 'File imported';
|
||||
else if (d.status === 'restored') msg = 'Item restored';
|
||||
else if (d.status === 'deleted_permanently') msg = 'Permanently deleted';
|
||||
else if (d.status === 'assigned') msg = 'Group assigned';
|
||||
|
||||
showToast(msg);
|
||||
loadInconsistencies();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Action failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInconsistencies(runNow = false) {
|
||||
if (runNow) document.getElementById('loading').style.display = 'block';
|
||||
try {
|
||||
const url = runNow ? `${API}/db/inconsistencies?run_now=true` : `${API}/db/inconsistencies`;
|
||||
const r = await fetch(url);
|
||||
const d = await r.json();
|
||||
|
||||
renderArchived(d.archived_items || []);
|
||||
renderMissing(d.missing_files || []);
|
||||
renderUntracked(d.untracked_files || []);
|
||||
renderOrphans(d.missing_group || []);
|
||||
|
||||
if (d.timestamp) {
|
||||
const date = new Date(d.timestamp * 1000);
|
||||
document.getElementById('timestamp').textContent = 'Last check: ' + date.toLocaleString();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Failed to load inconsistencies');
|
||||
} finally {
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderOrphans(files) {
|
||||
const container = document.getElementById('orphansList');
|
||||
if (files.length === 0) {
|
||||
container.innerHTML = 'No images with missing groups found.';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = files.map(f => `
|
||||
<div class="item">
|
||||
<div style="display:flex;align-items:center">
|
||||
<img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
|
||||
<div>
|
||||
<div class="filename">${f.filename}</div>
|
||||
<div class="info">Name: ${f.name || 'none'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn success" onclick="repair('${f.filename}', 'assign_group')">Auto-assign Group</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderArchived(files) {
|
||||
const container = document.getElementById('archivedList');
|
||||
if (files.length === 0) {
|
||||
container.innerHTML = 'No archived items found.';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = files.map(f => `
|
||||
<div class="item">
|
||||
<div style="display:flex;align-items:center">
|
||||
<img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
|
||||
<div>
|
||||
<div class="filename">${f.filename}</div>
|
||||
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn success" onclick="repair('${f.filename}', 'restore')">Restore</button>
|
||||
<button class="btn danger" onclick="if(confirm('Permanently delete ${f.filename}?')) repair('${f.filename}', 'delete_permanently')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderMissing(files) {
|
||||
const container = document.getElementById('missingList');
|
||||
if (files.length === 0) {
|
||||
container.innerHTML = 'No orphaned records found.';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = files.map(f => `
|
||||
<div class="item">
|
||||
<div style="display:flex;align-items:center">
|
||||
<img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
|
||||
<div>
|
||||
<div class="filename">${f.filename}</div>
|
||||
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn danger" onclick="repair('${f.filename}', 'delete_record')">Delete Record</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderUntracked(files) {
|
||||
const container = document.getElementById('untrackedList');
|
||||
if (files.length === 0) {
|
||||
container.innerHTML = 'No untracked files found.';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = files.map(f => `
|
||||
<div class="item">
|
||||
<div style="display:flex;align-items:center">
|
||||
<img src="${API}/output/${encodeURIComponent(f)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
|
||||
<div class="filename">${f}</div>
|
||||
</div>
|
||||
<button class="btn success" onclick="repair('${f}', 'import_file')">Import File</button>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function invalidateMetadata() {
|
||||
if (!confirm("Are you sure you want to invalidate all earlier metadata? This will clear all calculated completeness/pose/gaze/object details, enabling them to be clean-reprocessed by the background worker or manual backfiller.")) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/images/invalidate-metadata`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
showToast(d.message || "Metadata invalidated successfully");
|
||||
document.getElementById('backfillStatus').textContent = "All metadata reset. Background idle backfill will now start processing them one by one.";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Failed to invalidate metadata');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerBackfill() {
|
||||
document.getElementById('backfillStatus').textContent = "Triggering manual deep backfill on all images in background...";
|
||||
try {
|
||||
const r = await fetch(`${API}/images/backfill-metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force: true })
|
||||
});
|
||||
const d = await r.json();
|
||||
showToast("Deep backfill complete!");
|
||||
document.getElementById('backfillStatus').textContent = `Backfill complete! Processed: ${d.processed}, Failed: ${d.failed}, Total: ${d.total}.`;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Backfill failed');
|
||||
document.getElementById('backfillStatus').textContent = "Manual backfill failed or timed out.";
|
||||
}
|
||||
}
|
||||
|
||||
loadInconsistencies();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
171
tour-comfy/turntable_cache.py
Normal file
171
tour-comfy/turntable_cache.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
turntable_cache.py — persistent state for Qwen turntable generation.
|
||||
|
||||
State stored as JSON: {output_dir}/_turntable/{group_id}/state.json
|
||||
Views stored alongside: {output_dir}/_turntable/{group_id}/views/view_NNN_DDDdeg.png
|
||||
Final video: {output_dir}/_turntable/{group_id}/turntable.mp4
|
||||
|
||||
One state file per group tracks completed angles so background generation can
|
||||
resume after restart without re-rendering anything that's already on disk.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def cache_dir(output_dir: str, group_id: str) -> str:
|
||||
return os.path.join(output_dir, "_turntable", str(group_id))
|
||||
|
||||
|
||||
def state_path(output_dir: str, group_id: str) -> str:
|
||||
return os.path.join(cache_dir(output_dir, group_id), "state.json")
|
||||
|
||||
|
||||
def load_state(output_dir: str, group_id: str) -> Optional[dict]:
|
||||
try:
|
||||
import database
|
||||
db_state = database.load_turntable(group_id)
|
||||
if db_state:
|
||||
return db_state
|
||||
except Exception as e:
|
||||
print(f"[turntable_cache] DB load error: {e}")
|
||||
|
||||
p = state_path(output_dir, group_id)
|
||||
if not os.path.exists(p):
|
||||
return None
|
||||
try:
|
||||
with open(p) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_state(output_dir: str, group_id: str, state: dict):
|
||||
os.makedirs(cache_dir(output_dir, group_id), exist_ok=True)
|
||||
p = state_path(output_dir, group_id)
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
os.replace(tmp, p) # atomic
|
||||
|
||||
try:
|
||||
import database
|
||||
database.save_turntable(group_id, state)
|
||||
except Exception as e:
|
||||
print(f"[turntable_cache] DB save error: {e}")
|
||||
|
||||
|
||||
def init_state(
|
||||
output_dir: str,
|
||||
group_id: str,
|
||||
source_image: str,
|
||||
preferred_filename: str,
|
||||
n_views: int = 24,
|
||||
seed: int = 42,
|
||||
steps: int = 8,
|
||||
) -> dict:
|
||||
"""Create a fresh state dict and save it. Wipes any existing partial state."""
|
||||
import sys
|
||||
if _HERE not in sys.path:
|
||||
sys.path.insert(0, _HERE)
|
||||
from orbit_qwen import _angles_for
|
||||
|
||||
angles = _angles_for("turntable", n_views, 180.0)
|
||||
state = {
|
||||
"group_id": str(group_id),
|
||||
"preferred_filename": preferred_filename,
|
||||
"source_image": source_image,
|
||||
"seed": seed,
|
||||
"n_views": n_views,
|
||||
"steps": steps,
|
||||
"angles": angles,
|
||||
"views": {}, # deg_key (str) -> abs path
|
||||
"video_path": None,
|
||||
"completed": False,
|
||||
"started_at": time.time(),
|
||||
"completed_at": None,
|
||||
}
|
||||
save_state(output_dir, group_id, state)
|
||||
return state
|
||||
|
||||
|
||||
def deg_key(deg: float) -> str:
|
||||
return f"{deg:.1f}"
|
||||
|
||||
|
||||
def mark_view_done(output_dir: str, group_id: str, state: dict, deg: float, path: str):
|
||||
state["views"][deg_key(deg)] = path
|
||||
save_state(output_dir, group_id, state)
|
||||
|
||||
|
||||
def mark_completed(output_dir: str, group_id: str, state: dict, video_path: str):
|
||||
state["completed"] = True
|
||||
state["video_path"] = video_path
|
||||
state["completed_at"] = time.time()
|
||||
save_state(output_dir, group_id, state)
|
||||
|
||||
|
||||
def next_missing_angle(state: dict) -> Optional[float]:
|
||||
"""Return first angle not yet in state['views'], or None if all done."""
|
||||
done = state.get("views", {})
|
||||
for deg in state.get("angles", []):
|
||||
if deg_key(deg) not in done:
|
||||
return deg
|
||||
return None
|
||||
|
||||
|
||||
def list_cached_group_ids(output_dir: str) -> list:
|
||||
td = os.path.join(output_dir, "_turntable")
|
||||
if not os.path.isdir(td):
|
||||
return []
|
||||
return [
|
||||
d for d in os.listdir(td)
|
||||
if os.path.isfile(os.path.join(td, d, "state.json"))
|
||||
]
|
||||
|
||||
|
||||
def get_status_summary(output_dir: str) -> dict:
|
||||
"""Return {group_id: status_dict} for all groups that have a state file."""
|
||||
result = {}
|
||||
for gid in list_cached_group_ids(output_dir):
|
||||
st = load_state(output_dir, gid)
|
||||
if st:
|
||||
result[gid] = {
|
||||
"completed": st.get("completed", False),
|
||||
"n_done": len(st.get("views", {})),
|
||||
"n_total": st.get("n_views", 24),
|
||||
"video_path": st.get("video_path"),
|
||||
"preferred_filename": st.get("preferred_filename"),
|
||||
"started_at": st.get("started_at"),
|
||||
"completed_at": st.get("completed_at"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def delete_state(output_dir: str, group_id: str):
|
||||
"""Wipe all cached views, state, and video for this group."""
|
||||
import shutil
|
||||
d = cache_dir(output_dir, group_id)
|
||||
if os.path.isdir(d):
|
||||
shutil.rmtree(d)
|
||||
|
||||
try:
|
||||
import database
|
||||
database.delete_turntable(group_id)
|
||||
except Exception as e:
|
||||
print(f"[turntable_cache] DB delete error: {e}")
|
||||
|
||||
|
||||
def get_group_video(output_dir: str, group_id: str) -> Optional[str]:
|
||||
"""Return the video path if the turntable is complete and the file exists."""
|
||||
st = load_state(output_dir, group_id)
|
||||
if not st or not st.get("completed"):
|
||||
return None
|
||||
vp = st.get("video_path")
|
||||
if vp and os.path.exists(vp):
|
||||
return vp
|
||||
return None
|
||||
115
xhamster_downloader.py
Normal file
115
xhamster_downloader.py
Normal 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()
|
||||
Reference in New Issue
Block a user