• Implemented global offline mode for all HuggingFace-dependent modules to eliminate unauthenticated request warnings and prevent external connection attempts during startup and runtime.

   Changes
   • Global Offline Configuration: Added HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 to the environment variables at the top of edit_api.py, embeddings.py, watcher.py, and pose_llm/pose_llm_api.py.
   • Explicit Local Files Only: Updated all huggingface_hub.hf_hub_download calls in edit_api.py to include local_files_only=True, ensuring they never attempt to reach the Hub if models are already cached.
   • LLM Service Optimization: Updated AutoTokenizer and AutoModelForCausalLM calls in pose_llm/pose_llm_api.py to use local_files_only=True.
   • Consistency: Ensured that shared modules like embeddings.py which uses open_clip are also locked to offline mode to prevent background downloads.
This commit is contained in:
mike
2026-06-28 16:56:07 +02:00
parent da1fd4a26c
commit 3b9b9e829b
6 changed files with 881 additions and 239 deletions

View File

@@ -63,6 +63,14 @@ FastAPI :8500 (edit_api.py)
- **Systemd**: `comfyui-api`
- **DB**: PostgreSQL at `192.168.1.160:5433`, database `dv`, table `person`
#### In-Memory File Metadata Cache
To mitigate performance and network latency bottlenecks on remote/shared storage filesystems (e.g. `/mnt/zim`), the API server employs a light, thread-safe in-memory metadata cache (`_file_meta_cache`):
- **Cached Items**: File existence (`exists`) and modification timestamps (`mtime`) mapped by `filename`.
- **Cache TTL**: 5.0 seconds.
- **Idempotency & Invalidation**: Any mutation endpoints (such as crop, padding, duplication, background removal, or deletion) automatically update (`_update_cached_file_meta`) or explicitly delete (`_clear_cached_file_meta`) entries to keep cache and disk perfectly in sync.
- **Preloading Optimization**: Leverages cache queries in static indexing loops (`_sync_preloaded_images`, `_write_all_static`, `list_images`) and tracks changes to completely skip redundant static HTML rewrites if the preloaded image set is identical (`_last_preloaded_images_set`).
#### Endpoints Library
##### Core Generation & Upload
@@ -112,6 +120,12 @@ FastAPI :8500 (edit_api.py)
| `GET` | `/similar/{filename}` | Find visually similar images using database embeddings |
| `GET` | `/names` | Map `filename → person name` |
| `POST` | `/names/{filename}` | Map image file to custom character name |
| `POST` | `/images/{filename}/tags` | Set image tags list (supports tags like `ARCHIVED`, `HIDDEN`, `SOURCE`, `VISIBLE`, `LIKE`, `DISLIKE`, `21+`) |
| `POST` | `/images/{filename}/tag-action` | Add, remove, or toggle a specific tag on an image (automatically handles mutual exclusivity of `LIKE`/`DISLIKE`) |
| `POST` | `/images/{filename}/source` | Explicitly toggle marking an image as a source asset (sets/clears `is_source` and toggles the `SOURCE` tag) |
| `POST` | `/images/{filename}/estimate-21plus` | Analyze image with the WD tagger to estimate adult/sensitive rating and apply `21+` tag |
| `POST` | `/images/bulk-move` | Move a list of filenames to a specific target filmstrip (`VISIBLE`, `HIDDEN`, `ARCHIVED`, `SOURCE`) in bulk, updating tags/columns |
| `POST` | `/images/{filename}/undress` | Queue an asynchronous batch job utilizing the `UNDRESS_PROMPT` on the given reference image |
##### Visibility, Archiving & Deletion
| Method | Path | Purpose |
@@ -192,11 +206,13 @@ prompt
│ set negative: "deformed anatomy, watermark, logo"
├─ Canvas Expansion / Outpainting requested?
│ YES → if outpaint=True, append/use natural outpainting instructions:
"Naturally outpaint and extend the borders of the image to complete the scene."
│ YES → if outpaint=True:
├─ If source is transparent (RGBA): composite onto flat black background for maximum contrast.
│ ├─ Formulate dynamic prompt description: e.g. "replacing the black/white background areas"
│ └─ Append outpainting prompt instructions: "Naturally outpaint and extend..."
├─ Upload reference image(s) to ComfyUI /upload/image
│ image1 = source image
│ image1 = source image (composited RGB if outpainting transparent images)
│ image2 = wireframe pose guide frame (optional)
├─ Patch workflow graph nodes (prompt, size, seed, sampler)
@@ -315,6 +331,9 @@ Model: `buffalo_l`
| faceswap_source_video | TEXT | Source video for faceswapped clips |
| face_embedding | `vector(512)` | Face recognition embedding for visually matching character faces |
| is_source | `BOOLEAN` | Whether the file is a primary reference/source asset |
| tags | TEXT (JSON) | Custom user-applied tags/labels (e.g., `['VISIBLE', 'LIKE', '21+']`) |
| pose_description | TEXT | AI-generated descriptive text of the body pose |
| pose_skeleton | TEXT | Coordinates and scoring details of the 2D COCO-17 body pose skeleton |
---
@@ -352,10 +371,51 @@ To prevent desyncs during image transitions, crops, canvas expansion, or 2.2x zo
- A `ResizeObserver` is registered on the primary studio viewer image (`lbImg`).
- Automatically triggers a layout and geometry recalculation, cleanly and immediately adjusting the background transparency checkerboard grid and padding visual overlays to match the actual visual boundaries of the contained image.
#### Multiple Filmstrips System
The filmstrip bar has been updated with five tabs that filter the group's images dynamically:
- **Group Active**: The default filmstrip containing the main/preferred assets of the group.
- **Group Variant** (`VISIBLE`): Shows all standard visible variants within the group.
- **Hidden** (`HIDDEN`): Soft-hidden variants (excluded from main cycling).
- **Source** (`SOURCE`): Displays reference images explicitly marked as source assets.
- **Archived** (`ARCHIVED`): Displays soft-archived variants.
- **Drag-and-Drop Reclassification**: Users can drag any variant thumbnail from the filmstrip and drop it directly onto any of the tab buttons to instantly update its tag/status and reassign it to that list.
#### Bulk Multi-Select & Actions
Toggling the **Multi-select** button unlocks checkbox-based selection across the filmstrip:
- Provides a live selection counter.
- Enables bulk move/reclassification buttons to dispatch batch updates to `/images/bulk-move` (e.g., `Move to Group`, `Move to Hidden`, `Move to Source`, `Move to Archived`).
#### Seamless Non-Blinking State Updates
To prevent irritating browser reload blinks or blank screen latency during intensive image editing operations:
- **Local Metadata Cloning**: Duplication, manual cropping, padding, and background removal immediately clone the original's DB metadata values locally on the client and splice the new file record into the active filmstrip list.
- **Optimistic Merge**: During periodic background server syncs (`refreshNow`), an optimistic merge algorithm compares the local client-side list and the server JSON. It preserves any freshly created local assets that haven't yet been processed/synced on the backend, maintaining perfect continuity.
#### Studio Keyboard Shortcuts Reference
| Key | Action | Description |
|-----|--------|-------------|
| `P` | Toggle Privacy Mode | Replaces the page with a discrete "AI Assist" study-chat and disguises taskbar thumbnails. |
| `C` | Toggle Checkerboard | Toggle transparent background checkerboard grid. |
| `A` | Expand/Pad Canvas | Instantly trigger the manual padding toolbar. |
| `G` / `Alt+Enter` | Trigger Generation | Clicks the main "Generate" button (when Generate tab is active). |
| `ArrowLeft` / `ArrowRight` | Nav Filmstrip | Cycle through the current list of image variants (debounced). |
| `Home` / `End` | Jump to Bounds | Instantly jump to the first or last variant in the filmstrip. |
| `Space` | Play/Pause Video | Pauses or plays a running wireframe/clip inside the viewer. |
| `H` | Toggle Hidden | Reclasses the image between standard variants and hidden list. |
| `F` | Set as Preferred | Reorders the group, placing the current image at index 0 (as preferred thumbnail). |
| `S` | Toggle Source | Mark/unmark current image as a primary reference/source asset. |
| `E` | Run 21+ Tagger | Analyze image via WD tagger and apply/remove the `21+` tag. |
| `Delete` | Soft-Archive | Move the current image into the Archive filmstrip. |
| `ArrowUp` / `ArrowDown` | Shift Order | Move/reorder the current image earlier or later in the group (debounced). |
| `2` | Move to Second | Instantly reorder current image to the 2nd position in the group. |
| `9` | Move to Last-1 | Instantly reorder current image to the second to last position. |
| `Escape` | Cancel / Dismiss | Cancel cropping, padding, close variant picker, or exit selection mode. |
**Key state variables**:
- `lbCurrentGid` — active group ID
- `lbIdx` — current filmstrip position
- `_followLatestGid` — auto-follow newly generated image
- `_followLatestFilename` — auto-follow specific new output file
- `_sbSelectedAngles` — selected camera angle names
- `_sbWireframeRef` / `_sbWireframeTime` — wireframe video + frame time
- `privacyMode` — privacy overlay toggle (key: `P`)

View File

@@ -77,7 +77,7 @@ Scenery:
- 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

View File

@@ -2299,7 +2299,55 @@
// --- HYDRATION_START ---
const PRELOADED_IMAGES = [
"20260628_133544_pad_20260621_032709_image.png",
"20260628_165000_sc_image.png",
"20260628_164850_sc_sc_image.png",
"20260628_154819_sc_image.png",
"20260628_154609_image.png",
"20260628_154508_sc_image.png",
"20260628_154429_dup_20260621_032709_image.png",
"20260628_154339_image.png",
"20260628_153602_sc_dup_20260621_032709_image.nobg.png",
"20260628_153452_sc_image.png",
"20260628_153333_sc_image.png",
"20260628_151457_sc_image.png",
"20260628_151037_sc_image.png",
"up_6d311abe_face.png",
"20260628_150512_dup_20260621_032709_image.nobg.png",
"20260628_145644_dup_20260621_032709_image.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145317_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145305_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145254_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145243_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145232_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145221_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145209_pad_20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_145127_pad_20260628_144836_view_001_015deg.nobg.png",
"cg_2afca8fc_face.png",
"_turntable/cg_2afca8fc/views/20260628_144836_view_001_015deg.nobg.png",
"_turntable/cg_2afca8fc/views/20260628_144836_view_001_015deg.png",
"_turntable/cg_2afca8fc/views/20260628_144825_view_001_015deg.png",
"_turntable/cg_2afca8fc/views/20260628_144814_view_001_015deg.png",
"cg_75340e8e_face.png",
"cg_08203e88_face.png",
"cg_74544975_face.png",
"kk563t.png_face.png",
"20260628_143350_kk563t.nobg.png",
"20260628_143619_dup_20260628_143350_kk563t.nobg.png",
"20260628_143350_kk563t.png",
"20260628_143206_kk563t.png",
"pass-1.png_face.png",
"20260626_231655_sc_image.nobg.png",
"20260618_181600_image.png_face.png",
"_turntable/20260618_181600_image.png/views/view_000_000deg.png",
"_turntable/cg_74544975/views/20260628_140758_view_000_000deg.nobg.png",
"_turntable/cg_74544975/views/20260628_142102_dup_20260628_140758_view_000_000deg.nobg.png",
"_turntable/cg_74544975/views/20260628_140758_view_000_000deg.png",
"up_3a05a2de_face.png",
"up_aa1add4d_face.png",
"20260628_134529_dup_20260621_032709_image.nobg.png",
"_turntable/cg_7ec17537/views/20260628_135858_dup_20260628_035257_dup_20260628_033600_dup_view_000_000deg.nobg.png",
"cg_7ec17537_face.png",
"20260628_134529_dup_20260621_032709_image.png",
"_turntable/up_7b6eaabb/views/view_023_345deg.png",
"_turntable/up_7b6eaabb/views/view_022_330deg.png",
"_turntable/up_7b6eaabb/views/view_021_315deg.png",
@@ -2443,16 +2491,6 @@
"_turntable/pass-1.png/views/view_008_120deg.png",
"_turntable/pass-1.png/views/view_007_105deg.png",
"_turntable/pass-1.png/views/view_006_090deg.png",
"20260628_115611_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_115349_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_114542_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_114232_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_114212_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_113939_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_113737_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_113405_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_113320_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_113242_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_113152_sc_8_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
"20260628_111908_sc_best_01_person_0.874_2026-06-27_12-18-49.png",
"_turntable/pass-1.png/views/view_005_075deg.png",
@@ -2477,20 +2515,15 @@
"_turntable/cg_9bb8f3bb/views/view_010_150deg.png",
"_turntable/cg_9bb8f3bb/views/view_009_135deg.png",
"_turntable/cg_9bb8f3bb/views/view_008_120deg.png",
"20260628_105942_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"_turntable/cg_9bb8f3bb/views/view_007_105deg.png",
"_turntable/cg_9bb8f3bb/views/view_006_090deg.png",
"_turntable/cg_9bb8f3bb/views/view_005_075deg.png",
"20260628_105539_sc_pad_20260627_212800_4_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.nobg.png",
"_turntable/cg_9bb8f3bb/views/view_004_060deg.png",
"20260628_105040_pad_20260627_212800_4_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.nobg.png",
"_turntable/cg_9bb8f3bb/views/view_003_045deg.png",
"_turntable/cg_9bb8f3bb/views/view_002_030deg.png",
"_turntable/cg_9bb8f3bb/views/view_001_015deg.png",
"_turntable/cg_9bb8f3bb/views/view_000_000deg.png",
"_turntable/pa01.png/views/view_023_345deg.png",
"20260628_105307_dup_20260628_105040_pad_20260627_212800_4_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
"20260628_105040_pad_20260627_212800_4_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
"_turntable/pa01.png/views/view_022_330deg.png",
"_turntable/pa01.png/views/view_021_315deg.png",
"20260627_212812_5_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
@@ -2563,14 +2596,6 @@
"_turntable/up_2687be75/views/view_002_030deg.png",
"_turntable/up_2687be75/views/view_001_015deg.png",
"_turntable/up_2687be75/views/view_000_000deg.png",
"20260628_061520_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_061440_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_061355_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_061322_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_061249_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_061152_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_061006_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_060550_sc_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_060113_sc_best_00_person_0.874_2026-06-27_12-18-49.png",
"20260628_055858_sc_best_01_person_0.874_2026-06-27_12-18-49.png",
"_turntable/cg_7ec17537/views/20260628_055541_sc_dup_20260628_033600_dup_view_000_000deg.png",
@@ -2647,11 +2672,6 @@
"20260618_053802_image.png_face.png",
"20260628_032232_dup_20260618_053932_7_20260618_053802_image.nobg.nobg.nobg.png",
"20260628_032232_dup_20260618_053932_7_20260618_053802_image.nobg.nobg.png",
"pass-1.png_face.png",
"20260628_031641_dup_20260628_031119_pad_pass-1.nobg.png",
"20260628_031119_pad_pass-1.nobg.png",
"20260628_031316_pad_pass-1.png",
"20260628_031119_pad_pass-1.png",
"pa01.png_face.png",
"20260628_030838_pa01.png",
"20260628_030808_pa01.png",
@@ -2659,51 +2679,19 @@
"20260628_030532_pa01.png",
"20260628_030241_pa01.png",
"20260628_030115_pa01.png",
"kk563t.png_face.png",
"20260628_025504_pad_pass-1.png",
"up_e25ad102_face.png",
"20260628_020853_pad_20260628_015651_img_12.nobg.png",
"20260628_020642_pad_20260628_015651_img_12.nobg.png",
"20260628_020610_pad_20260628_015651_img_12.nobg.png",
"20260628_020546_pad_20260628_015651_img_12.nobg.png",
"20260628_020519_pad_20260628_015651_img_12.nobg.png",
"20260628_020428_pad_20260628_015651_img_12.nobg.png",
"20260628_020302_pad_20260628_015651_img_12.nobg.png",
"20260628_020125_pad_20260628_015651_img_12.nobg.png",
"20260628_015815_pad_20260628_015651_img_12.nobg.png",
"20260628_015815_pad_20260628_015651_img_12.png",
"20260628_015707_img_12.png",
"20260628_015651_img_12.png",
"20260615_153426_img_12.png",
"20260628_015423_img_12.png",
"cg_5991f3f2_face.png",
"20260628_012806_pad_20260624_143523_Screenshot_From_2026-06-17_07-41-27.nobg.nobg.png",
"20260628_014614_p10.nobg.png",
"p10.png_face.png",
"20260628_014614_p10.png",
"20260628_014603_p10.png",
"up_2687be75_face.png",
"20260628_013521_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_013511_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_013459_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_013429_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.nobg.png",
"20260628_013429_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.png",
"20260628_013412_pad_20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.png",
"20260628_012832_pad_20260624_143523_Screenshot_From_2026-06-17_07-41-27.nobg.nobg.png",
"_turntable/cg_5991f3f2/views/view_022_330deg.png",
"20260628_011700_pad_20260624_154318_img_72.png",
"20260628_012006_pad_20260624_154318_img_72.png",
"20260628_011837_pad_20260624_154318_img_72.png",
"20260628_012240_crop_20260628_011837_pad_20260624_154318_img_72.png",
"20260628_011227_dup_Pasted image (9).png",
"20260628_011118_pad_20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.nobg.png",
"20260628_010957_pad_20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.nobg.png",
"20260628_010947_pad_20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.nobg.png",
"20260628_010858_pad_20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.nobg.png",
"20260628_010858_pad_20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"20260628_010841_pad_20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"20260628_010755_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"20260628_010630_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"20260628_010408_image.png",
"20260627_203626_image.png",
"up_7b6eaabb_face.png",
@@ -2738,12 +2726,6 @@
"_turntable/up_6ed4293c/views/view_002_030deg.png",
"_turntable/up_6ed4293c/views/view_001_015deg.png",
"_turntable/up_6ed4293c/views/view_000_000deg.png",
"20260628_001216_pad_20260626_035101_pad_20260626_034830_jb.nobg.png",
"20260627_232502_pad_20260626_035101_pad_20260626_034830_jb.nobg.png",
"20260627_232843_pad_20260626_035101_pad_20260626_034830_jb.nobg.png",
"20260627_232526_pad_20260626_035101_pad_20260626_034830_jb.nobg.png",
"20260627_232302_pad_20260627_232133_image.png",
"20260627_232238_pad_20260627_232133_image.png",
"up_6ed4293c_face.png",
"20260627_232209_image.png",
"20260627_232151_image.png",
@@ -3259,7 +3241,6 @@
"20260627_161342_image.nobg.png",
"20260618_053458_image.png_face.png",
"20260627_161342_image.png",
"20260627_161217_pad_20260627_161055_image.png",
"20260627_161055_image.png",
"20260627_160901_mr_sc_image.png",
"20260627_160718_mr_sc_image.png",
@@ -3275,7 +3256,6 @@
"20260620_031100_20150913_211324.jpg",
"20260627_154745_20150913_211324.jpg",
"20260627_154404_20150913_211324.jpg",
"20260627_153734_pad_20260620_031100_20150913_211324.jpg",
"20260625_050554_sc_3_20260618_173728_image.nobg.png",
"_turntable/cg_ad73335e/views/view_022_330deg.png",
"_turntable/cg_ad73335e/views/view_021_315deg.png",
@@ -3286,11 +3266,6 @@
"cg_ac6274f0_face.png",
"20260618_180939_image.png_face.png",
"img_51.png_face.png",
"20260627_150448_pad_20260627_150234_pad_20260627_150207_pad_20260627_145719_img_51.png",
"20260627_150351_pad_20260627_150234_pad_20260627_150207_pad_20260627_145719_img_51.png",
"20260627_150251_pad_20260627_150234_pad_20260627_150207_pad_20260627_145719_img_51.png",
"20260627_150234_pad_20260627_150207_pad_20260627_145719_img_51.png",
"20260627_150207_pad_20260627_145719_img_51.png",
"20260627_150013_img_51.png",
"20260627_145730_img_51.png",
"20260627_145719_img_51.png",
@@ -3345,27 +3320,13 @@
"up_ae825fce_face.png",
"_turntable/cg_4e2b6449/views/view_000_000deg.png",
"_turntable/cg_4e2b6449/views/view_001_015deg.png",
"20260627_134506_pad_20260627_130837_image.png",
"20260627_134409_pad_20260627_130837_image.png",
"_turntable/cg_4e2b6449/views/view_003_045deg.png",
"_turntable/cg_4e2b6449/views/view_002_030deg.png",
"20260627_133359_pad_20260627_132332_image.nobg.png",
"20260627_133236_pad_20260627_132332_image.nobg.png",
"20260627_133149_pad_20260627_132332_image.nobg.png",
"20260627_133128_pad_20260627_132332_image.nobg.png",
"20260627_133024_pad_20260627_132332_image.nobg.png",
"20260627_132913_pad_20260627_132332_image.nobg.png",
"20260627_132744_pad_20260627_132332_image.nobg.png",
"20260627_132456_pad_20260627_132332_image.nobg.png",
"20260627_132402_pad_20260627_132332_image.nobg.png",
"20260627_132402_pad_20260627_132332_image.png",
"20260627_132332_image.png",
"_turntable/cg_65f71c4d/views/view_001_015deg.png",
"_turntable/cg_65f71c4d/views/view_000_000deg.png",
"_turntable/cg_65f71c4d/views/view_023_345deg.png",
"20260627_132208_pad_20260626_224632_sc_image.png",
"20260627_132151_sc_image.png",
"20260627_132035_pad_20260626_224632_sc_image.png",
"_turntable/cg_870f3f93/views/view_023_345deg.png",
"_turntable/cg_870f3f93/views/view_018_270deg.png",
"_turntable/cg_870f3f93/views/view_016_240deg.png",
@@ -3382,12 +3343,7 @@
"_turntable/cg_870f3f93/views/view_003_045deg.png",
"_turntable/cg_870f3f93/views/view_001_015deg.png",
"_turntable/cg_870f3f93/views/view_000_000deg.png",
"20260627_131628_pad_20260622_053758_2_20260621_115215_image.png",
"20260627_131605_pad_20260622_053758_2_20260621_115215_image.png",
"20260627_131420_2_20260621_115215_image.png",
"20260627_131120_pad_20260627_130837_image.png",
"20260627_131008_pad_20260627_130837_image.png",
"20260627_130925_pad_20260627_130837_image.png",
"20260627_130837_image.png",
"20260627_130827_image.png",
"_turntable/20260618_133729_image.png/views/view_021_315deg.png",
@@ -3482,7 +3438,6 @@
"20260627_123945_8_20260627_123731_image.png",
"20260627_123935_image.png",
"20260627_123928_7_20260627_123731_image.png",
"20260627_123911_6_20260627_123731_image.png",
"20260627_123855_5_20260627_123731_image.png",
"20260627_123838_4_20260627_123731_image.png",
"20260627_123822_3_20260627_123731_image.png",
@@ -3749,9 +3704,9 @@
"_turntable/cg_7ec17537/views/view_003_045deg.png",
"_turntable/cg_7ec17537/views/view_002_030deg.png",
"_turntable/cg_7ec17537/views/view_001_015deg.png",
"_turntable/cg_7ec17537/views/20260628_033831_dup_20260628_033600_dup_view_000_000deg.png",
"_turntable/cg_7ec17537/views/20260628_033600_dup_view_000_000deg.png",
"_turntable/cg_7ec17537/views/view_000_000deg.png",
"_turntable/cg_7ec17537/views/20260628_033600_dup_view_000_000deg.png",
"_turntable/cg_7ec17537/views/20260628_033831_dup_20260628_033600_dup_view_000_000deg.png",
"_turntable/up_154b502c/views/view_023_345deg.png",
"_turntable/up_154b502c/views/view_022_330deg.png",
"_turntable/up_154b502c/views/view_021_315deg.png",
@@ -4287,8 +4242,6 @@
"_turntable/cg_18040099/views/view_002_030deg.png",
"_turntable/cg_18040099/views/view_001_015deg.png",
"_turntable/cg_18040099/views/view_000_000deg.png",
"20260627_044054_pad_20260621_205908_0_20260621_205151_image.png",
"20260627_044022_pad_20260621_205908_0_20260621_205151_image.png",
"20260627_043800_dup_20260624_055327_image.nobg.nobg.png",
"20260624_125511_dup_20260624_055327_image.nobg.nobg.png",
"20260624_125511_dup_20260624_055327_image.nobg.nobg.nobg.png",
@@ -4296,16 +4249,6 @@
"20260627_043555_dup_20260624_055327_image.nobg.nobg.png",
"20260627_043455_dup_20260624_055327_image.nobg.png",
"20260624_125511_dup_20260624_055327_image.nobg.png",
"20260627_043155_crop_20260627_042652_pad_20260627_042554_pad_20260627_042228_image.nobg.png",
"20260627_043055_pad_20260627_042228_image.nobg.png",
"20260627_042900_pad_20260627_042554_pad_20260627_042228_image.nobg.png",
"20260627_042835_crop_20260627_042652_pad_20260627_042554_pad_20260627_042228_image.nobg.png",
"20260627_042719_pad_20260627_042554_pad_20260627_042228_image.nobg.png",
"20260627_042652_pad_20260627_042554_pad_20260627_042228_image.nobg.png",
"20260627_042652_pad_20260627_042554_pad_20260627_042228_image.nobg.nobg.png",
"20260627_042620_pad_20260627_042228_image.nobg.png",
"20260627_042554_pad_20260627_042228_image.nobg.png",
"20260627_042554_pad_20260627_042228_image.nobg.nobg.png",
"20260627_042525_image.nobg.png",
"20260627_042228_image.nobg.png",
"20260627_042228_image.png",
@@ -4317,8 +4260,6 @@
"20260627_041236_image.nobg.png",
"20260627_041236_image.png",
"20260621_033259_image.nobg.png",
"20260627_040944_pad_20260627_030425_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"20260627_040926_pad_20260627_030425_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"20260627_040817_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"20260627_040738_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"20260627_040304_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
@@ -4348,7 +4289,6 @@
"20260627_030739_sc_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"cg_18040099_face.png",
"20260627_030425_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"20260627_030441_crop_20260627_030425_dup_20260624_125526_dup_20260624_055327_image.nobg.png",
"20260627_030425_dup_20260624_125526_dup_20260624_055327_image.nobg.nobg.png",
"20260627_030344_sc_image.png",
"20260627_030145_sc_dup_20260624_055327_image.nobg.png",
@@ -4402,10 +4342,6 @@
"_turntable/20260618_181600_image.png/views/view_003_090deg.png",
"_turntable/20260618_181600_image.png/views/view_002_060deg.png",
"_turntable/20260618_181600_image.png/views/view_001_030deg.png",
"_turntable/20260618_181600_image.png/views/view_000_000deg.png",
"20260626_234431_crop_20260626_231655_sc_image.nobg.png",
"20260626_231655_sc_image.nobg.png",
"20260618_181600_image.png_face.png",
"20260626_234334_dup_20260626_231655_sc_image.png",
"20260626_231655_sc_image.png",
"20260626_231349_sc_image.png",
@@ -4688,7 +4624,6 @@
"20260618_015041_20260618_004313_20260616_021938_20160903_200935.nobg.nobg.png",
"20260626_124145_20160903_200935.jpg",
"cg_4671b6c4_face.png",
"20260626_152247_crop_20260626_152107_20160903_200935.nobg.nobg.nobg.nobg.png",
"20260626_152107_20160903_200935.nobg.png",
"20260626_151945_dup_20260626_151844_20160903_200935.nobg.png",
"20260626_151844_20160903_200935.nobg.png",
@@ -5056,25 +4991,10 @@
"_turntable/b1.png/views/view_002_030deg.png",
"_turntable/b1.png/views/view_001_015deg.png",
"_turntable/b1.png/views/view_000_000deg.png",
"20260626_053719_pad_20260619_224737_img_14.nobg.png",
"20260626_053719_pad_20260619_224737_img_14.nobg.nobg.png",
"20260626_053754_pad_20260619_224737_img_14.png",
"20260626_053719_pad_20260619_224737_img_14.png",
"20260626_043734_7_20260618_053802_image.nobg.nobg.png",
"20260626_043915_crop_20260626_043734_7_20260618_053802_image.nobg.nobg.png",
"20260626_043734_7_20260618_053802_image.nobg.png",
"20260628_032038_dup_20260618_053932_7_20260618_053802_image.nobg.png",
"20260618_053932_7_20260618_053802_image.nobg.png",
"20260626_041550_sc_pad_20260626_034830_jb.nobg.png",
"20260626_041539_sc_pad_20260626_034830_jb.nobg.png",
"20260626_041515_sc_pad_20260626_034830_jb.nobg.png",
"20260626_041456_sc_pad_20260626_034830_jb.nobg.png",
"20260626_041410_sc_pad_20260626_034830_jb.nobg.png",
"20260626_041256_sc_pad_20260626_034830_jb.nobg.png",
"20260626_040655_sc_pad_20260626_034830_jb.nobg.png",
"20260626_040550_sc_pad_20260626_034830_jb.nobg.png",
"20260626_035131_pose_pad_20260626_034830_jb.nobg.png",
"20260626_035101_pad_20260626_034830_jb.nobg.png",
"20260628_032038_dup_20260618_053932_7_20260618_053802_image.nobg.png",
"20260626_034830_jb.nobg.png",
"20260626_034830_jb.png",
"20260626_034708_jb.png",
@@ -5094,11 +5014,6 @@
"20260626_030918_jb1.png",
"20260626_030908_1_20260618_050846_img_4.png",
"20260626_025708_2_20260625_205722_image.png",
"20260626_025338_pad_20260625_205913_8_20260625_205722_image.png",
"20260626_023528_pad_20260625_205913_8_20260625_205722_image.png",
"20260626_021942_pad_20260625_205913_8_20260625_205722_image.png",
"20260626_021533_pad_20260625_205828_4_20260625_205722_image.png",
"20260626_014422_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"20260626_005504_Pasted image (9).nobg.png",
"20260626_005504_Pasted image (9).png",
@@ -5112,7 +5027,6 @@
"20260625_231601_sc_image.png",
"20260625_225929_sc_image.png",
"20260625_223304_p12.png",
"20260625_213330_pad_20260625_193323_image.png",
"20260625_210437_image.png",
"20260625_210432_image.png",
"20260625_210424_image.png",
@@ -5210,8 +5124,8 @@
"20260625_021910_image.png",
"20260625_021702_sc_image.png",
"20260625_021615_sc_image.png",
"20260625_015711_sc_image.png",
"20260625_020212_dup_20260625_015711_sc_image.png",
"20260625_015711_sc_image.png",
"20260625_015711_sc_image.nobg.png",
"20260625_015900_sc_image.png",
"20260625_015413_sc_image.png",
@@ -5422,8 +5336,6 @@
"20260624_164404_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png",
"20260624_164353_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png",
"20260624_162601_mr_0_20260622_093058_image.nobg.png",
"20260624_164240_crop_20260624_162342_1_20260622_093058_image.png",
"20260624_164218_crop_20260624_162601_mr_0_20260622_093058_image.nobg.png",
"20260624_164059_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png",
"20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png",
"20260624_164116_dup_20260624_163951_dup_20260624_163352_dup_20260624_162601_mr_0_20260622_093058_image.png",
@@ -9308,12 +9220,38 @@
cancelManualCrop();
if (asCopy && d.new_filename) {
showToast('Cropped to copy', 'success');
const newFn = d.new_filename;
// Clone metadata locally to avoid refresh blink
filePrompts[newFn] = filePrompts[fname];
filePoses[newFn] = filePoses[fname];
fileHasBg[newFn] = fileHasBg[fname];
fileHasClothing[newFn] = fileHasClothing[fname];
fileContentType[newFn] = fileContentType[fname] || 'image';
fileSourceRefs[newFn] = [fname];
fileHidden[newFn] = false;
fileIsSource[newFn] = false;
fileArchived[newFn] = false;
// Insert the cropped copy right after the original in the studio strip.
lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + d.new_filename + '?t=' + Date.now());
lbNames.splice(lbIdx + 1, 0, d.new_filename);
lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + newFn + '?t=' + Date.now());
lbNames.splice(lbIdx + 1, 0, newFn);
lbIdx = lbIdx + 1;
const group = groupData.get(lbCurrentGid);
if (group) {
const gIdx = group.names.indexOf(fname);
if (gIdx !== -1) {
group.urls.splice(gIdx + 1, 0, lbUrls[lbIdx]);
group.names.splice(gIdx + 1, 0, lbNames[lbIdx]);
group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]);
}
}
updateStudio();
refreshNow();
_followLatestFilename = newFn;
_followLatestGid = lbCurrentGid;
refreshNow(true);
} else {
showToast('Cropped', 'success');
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
@@ -9414,6 +9352,9 @@
fileHasClothing[newFn] = fileHasClothing[fname];
fileContentType[newFn] = 'image';
fileSourceRefs[newFn] = [fname];
fileHidden[newFn] = false;
fileIsSource[newFn] = false;
fileArchived[newFn] = false;
lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + newFn + '?t=' + Date.now());
lbNames.splice(lbIdx + 1, 0, newFn);
@@ -9421,10 +9362,13 @@
const group = groupData.get(lbCurrentGid);
if (group) {
group.urls.splice(lbIdx, 0, lbUrls[lbIdx]);
group.names.splice(lbIdx, 0, lbNames[lbIdx]);
const gIdx = group.names.indexOf(fname);
if (gIdx !== -1) {
group.urls.splice(gIdx + 1, 0, lbUrls[lbIdx]);
group.names.splice(gIdx + 1, 0, lbNames[lbIdx]);
group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]);
}
}
updateStudio();
_followLatestFilename = newFn;
@@ -9464,20 +9408,24 @@
fileHasClothing[newFn] = fileHasClothing[fname];
fileContentType[newFn] = fileContentType[fname] || 'image';
fileSourceRefs[newFn] = [fname];
fileHidden[newFn] = false;
fileIsSource[newFn] = false;
fileArchived[newFn] = false;
// Insert the duplicate right after the current image in the studio strip
lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + newFn + '?t=' + Date.now());
lbNames.splice(lbIdx + 1, 0, newFn);
lbIdx = lbIdx + 1;
// Append the duplicate to the end of the filmstrip
lbUrls.push(IMAGE_FOLDER + newFn + '?t=' + Date.now());
lbNames.push(newFn);
lbIdx = lbUrls.length - 1;
const group = groupData.get(lbCurrentGid);
if (group) {
group.urls.splice(lbIdx, 0, lbUrls[lbIdx]);
group.names.splice(lbIdx, 0, lbNames[lbIdx]);
group.urls.push(lbUrls[lbIdx]);
group.names.push(lbNames[lbIdx]);
group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]);
}
updateStudio();
_followLatestFilename = newFn;
_followLatestGid = lbCurrentGid;
refreshNow(true);
} else {
@@ -10507,21 +10455,50 @@
const fresh = groupData.get(lbCurrentGid);
if (fresh) {
const curName = lbNames[lbIdx];
const newIdx = fresh.names.indexOf(curName);
// Snapshot ref filenames before overwriting lbNames
const oldRefNames = _sbRefIndices.map(i => lbNames[i]).filter(Boolean);
lbUrls = fresh.urls;
lbNames = fresh.names;
// Keep lbIdx stable when current image not yet in fresh data
// (e.g. duplicate just created — server JSON may lag by the debounce window).
// Optimistic merge: retain any locally added names/urls that are not yet in the freshly loaded server list
const mergedNames = [...fresh.names];
const mergedUrls = [...fresh.urls];
for (let i = 0; i < lbNames.length; i++) {
const localName = lbNames[i];
if (!mergedNames.includes(localName)) {
if (i === 0) {
mergedNames.unshift(localName);
mergedUrls.unshift(lbUrls[i]);
} else {
const prevLocal = lbNames[i - 1];
const freshIdx = mergedNames.indexOf(prevLocal);
if (freshIdx !== -1) {
mergedNames.splice(freshIdx + 1, 0, localName);
mergedUrls.splice(freshIdx + 1, 0, lbUrls[i]);
} else {
mergedNames.push(localName);
mergedUrls.push(lbUrls[i]);
}
}
}
}
// Update groupData as well so that other components have correct metadata
fresh.names = mergedNames;
fresh.urls = mergedUrls;
fresh.visibleIdx = fresh.names.map((_, idx) => idx).filter(idx => !fileHidden[fresh.names[idx]]);
lbUrls = mergedUrls;
lbNames = mergedNames;
const newIdx = lbNames.indexOf(curName);
if (newIdx >= 0) {
lbIdx = newIdx;
} else if (lbIdx >= fresh.names.length) {
lbIdx = fresh.names.length - 1;
} else if (lbIdx >= lbNames.length) {
lbIdx = lbNames.length - 1;
}
// Re-map multi-ref selections to new positions by filename
_sbRefIndices = oldRefNames
.map(n => fresh.names.indexOf(n))
.map(n => lbNames.indexOf(n))
.filter(i => i >= 0);
updateStudio();
}
@@ -11266,13 +11243,32 @@
const r = await fetch(`${API}/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
if (!r.ok) { console.error('set-preferred failed', r.status); return; }
const data = await r.json();
// Update sort orders locally
const gid = data.group_id;
fileSortOrders[filename] = 0;
const fresh = groupData.get(gid);
if (fresh) {
fresh.names.forEach(n => {
if (n !== filename) {
fileSortOrders[n] = (fileSortOrders[n] || 0) + 1;
}
});
const paired = fresh.names.map((name, i) => ({ name, url: fresh.urls[i] }));
paired.sort((a, b) => {
const oa = fileSortOrders[a.name] ?? 9999;
const ob = fileSortOrders[b.name] ?? 9999;
if (oa !== ob) return oa - ob;
return a.name.length - b.name.length;
});
fresh.names = paired.map(p => p.name);
fresh.urls = paired.map(p => p.url);
fresh.visibleIdx = fresh.names.map((_, i) => i).filter(i => !fileHidden[fresh.names[i]]);
}
// Re-open picker to reflect new order
showVariantPicker(filename);
// Poll for the freshly-extracted face crop (async on the server).
showVariantPicker(gid);
loadFaceThumb(gid, 8);
setTimeout(refreshNow, 2000);
} catch(e) { console.error(e); }
}
@@ -12037,6 +12033,9 @@
// Refresh UI as each task completes; auto-follow to newest image
if (job.done > prevDone) {
prevDone = job.done;
if (job.latest_output) {
_followLatestFilename = job.latest_output;
}
_followLatestGid = lbCurrentGid;
refreshNow(true);
}
@@ -12057,6 +12056,9 @@
_sbSelectedAngles.clear();
_sbPoseEdits = {};
_sbGenJobId = null;
if (job.latest_output) {
_followLatestFilename = job.latest_output;
}
_followLatestGid = lbCurrentGid;
refreshNow(true);
renderSidebarGenerate();
@@ -13150,10 +13152,43 @@
const label = d.used_sam2 ? 'Done (SAM2)' : 'Done (rembg)';
if (statusEl) statusEl.textContent = label;
showToast('Background removed', 'success');
refreshNow(true);
// Build URL same way as all gallery images: IMAGE_FOLDER + filename
const nobgUrl = IMAGE_FOLDER + d.nobg_filename + '?t=' + Date.now();
const newFn = d.nobg_filename;
const nobgUrl = IMAGE_FOLDER + newFn + '?t=' + Date.now();
_nobgSidecars.set(fname, nobgUrl);
// Clone metadata locally to avoid refresh blink
filePrompts[newFn] = filePrompts[fname];
filePoses[newFn] = (filePoses[fname] || '') + ' (No BG)';
fileHasBg[newFn] = false;
fileHasClothing[newFn] = fileHasClothing[fname];
fileContentType[newFn] = 'image';
fileSourceRefs[newFn] = [fname];
fileHidden[newFn] = false;
fileIsSource[newFn] = false;
fileArchived[newFn] = false;
// Insert the sidecar right after the current image in the studio strip
lbUrls.splice(lbIdx + 1, 0, nobgUrl);
lbNames.splice(lbIdx + 1, 0, newFn);
const group = groupData.get(lbCurrentGid);
if (group) {
const gIdx = group.names.indexOf(fname);
if (gIdx !== -1) {
group.urls.splice(gIdx + 1, 0, nobgUrl);
group.names.splice(gIdx + 1, 0, newFn);
group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]);
}
}
// Move to the new no-bg image
lbIdx = lbIdx + 1;
updateStudio();
_followLatestFilename = newFn;
_followLatestGid = lbCurrentGid;
refreshNow(true);
// Preload before swap to avoid black flash
const preload = new Image();
preload.onload = () => {
@@ -13197,16 +13232,10 @@
const d = await r.json();
if (statusEl) statusEl.textContent = 'Done (rembg)';
showToast('Background removed (rembg)', 'success');
refreshNow(true);
const nobgUrl = IMAGE_FOLDER + d.nobg_filename + '?t=' + Date.now();
_nobgSidecars.set(fname, nobgUrl);
const preload = new Image();
preload.onload = () => {
const img = document.getElementById('lbImg');
if (img) img.src = nobgUrl;
fileHasBg[fname] = false;
_bustStudioImage(fname);
toggleCheckerboard(true);
};
preload.src = nobgUrl;
refreshNow(true);
}
} catch (e) { if (statusEl) statusEl.textContent = 'Error: ' + e; }
if (btn) btn.disabled = false;
@@ -13248,25 +13277,42 @@
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/set-preferred`, {method:'POST'});
if (!r.ok) return;
// Update sort orders locally to match backend logic
const gid = lbCurrentGid;
fileSortOrders[fname] = 0;
const url = lbUrls[lbIdx];
const otherUrls = lbUrls.filter((_, i) => i !== lbIdx);
const otherNames = lbNames.filter((_, i) => i !== lbIdx);
lbUrls = [url, ...otherUrls];
lbNames = [fname, ...otherNames];
lbIdx = 0;
updateStudio();
refreshNow(true);
// Fire-and-forget face extraction for face-book
fetch(`${API}/images/${encodeURIComponent(fname)}/extract-face`, { method: 'POST' })
.then(r => r.ok ? r.json() : null)
.then(d => {
if (d?.status === 'queued') {
showToast('Extracting face reference…', 'info');
loadFaceThumb(lbCurrentGid, 8); // poll until the crop lands
const fresh = groupData.get(gid);
if (fresh) {
fresh.names.forEach(n => {
if (n !== fname) {
fileSortOrders[n] = (fileSortOrders[n] || 0) + 1;
}
})
.catch(() => {});
});
// Re-sort local group data
const paired = fresh.names.map((name, i) => ({ name, url: fresh.urls[i] }));
paired.sort((a, b) => {
const oa = fileSortOrders[a.name] ?? 9999;
const ob = fileSortOrders[b.name] ?? 9999;
if (oa !== ob) return oa - ob;
return a.name.length - b.name.length;
});
fresh.names = paired.map(p => p.name);
fresh.urls = paired.map(p => p.url);
fresh.visibleIdx = fresh.names.map((_, i) => i).filter(i => !fileHidden[fresh.names[i]]);
lbUrls = fresh.urls;
lbNames = fresh.names;
lbIdx = lbNames.indexOf(fname);
}
updateStudio();
showToast('Set as preferred', 'success');
// Delayed refresh to reconcile with server without disrupting local state
setTimeout(refreshNow, 2000);
// Poll for the freshly-extracted face crop (async on the server).
loadFaceThumb(gid, 8);
} catch(e) { console.error(e); }
}

View File

@@ -118,6 +118,43 @@ IDLE_THRESHOLD = 45 # seconds of inactivity before background gen starts
IDLE_CHECK_INTERVAL = 4 # polling interval (seconds)
# --- File Metadata In-Memory Cache (resolves performance bottlenecks on /mnt/zim) ---
_file_meta_cache = {} # filename -> (exists, mtime, cache_time)
_file_meta_cache_lock = threading.Lock()
def _get_cached_file_meta(filename: str, output_dir: str):
now = time.time()
with _file_meta_cache_lock:
cached = _file_meta_cache.get(filename)
if cached and (now - cached[2] < 5.0):
return cached[0], cached[1]
fpath = os.path.join(output_dir, filename)
exists = os.path.exists(fpath)
mtime = 0.0
if exists:
try:
mtime = os.path.getmtime(fpath)
except Exception:
pass
with _file_meta_cache_lock:
_file_meta_cache[filename] = (exists, mtime, now)
return exists, mtime
def _update_cached_file_meta(filename: str, exists: bool = True, mtime: float = None):
if mtime is None:
mtime = time.time()
with _file_meta_cache_lock:
_file_meta_cache[filename] = (exists, mtime, time.time())
def _clear_cached_file_meta(filename: str):
with _file_meta_cache_lock:
if filename in _file_meta_cache:
del _file_meta_cache[filename]
_last_preloaded_images_set = None
@app.middleware("http")
async def _track_activity(request, call_next):
global _last_request_time
@@ -126,6 +163,7 @@ async def _track_activity(request, call_next):
def _sync_preloaded_images():
"""Update PRELOADED_IMAGES in car.html (both source and output) to match current DB state."""
global _last_preloaded_images_set
try:
output_dir = _load_output_dir()
paths = [
@@ -134,11 +172,21 @@ def _sync_preloaded_images():
]
persons = database.list_persons(include_archived=False)
# Only include if file actually exists on disk
db_images = [p[0] for p in persons if os.path.exists(os.path.join(output_dir, p[0]))]
# Only include if file actually exists on disk, using the fast cache
db_images = []
for p in persons:
exists, mtime = _get_cached_file_meta(p[0], output_dir)
if exists:
db_images.append(p[0])
# Sort by mtime, newest first
db_images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
db_images.sort(key=lambda x: _get_cached_file_meta(x, output_dir)[1], reverse=True)
# Avoid redundant HTML rewrites if the preloaded set is unchanged
current_set = set(db_images)
if _last_preloaded_images_set is not None and _last_preloaded_images_set == current_set:
return
_last_preloaded_images_set = current_set
images_json = json.dumps(db_images, indent=12).strip()
# Format for JS insertion
@@ -195,7 +243,8 @@ def _watch_frontend():
def _load_wireframe_dir() -> str:
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
return conf.get("wireframe_dir", "/mnt/zim/tour-comfy/wireframe")
d = conf.get("wireframe_dir", "/mnt/zim/tour-comfy/wireframe")
return os.path.expanduser(d)
def _load_faceswap_model_path() -> str:
@@ -1055,9 +1104,9 @@ def _faceswap_worker_ff(job_id: str, model_filename: str, video_name: str,
source_refs=json.dumps([model_filename]),
sort_order=database.get_next_sort_order(group_id),
)
_invalidate_static()
jobs[job_id]['status'] = 'done'
jobs[job_id]['output'] = out_name
_invalidate_static()
except Exception as e:
print(f'[faceswap-ff] error: {e}')
@@ -1270,7 +1319,7 @@ jobs: dict[str, dict] = {}
def _load_output_dir() -> str:
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
d = conf["output_dir"]
d = os.path.expanduser(conf["output_dir"])
if not os.path.isabs(d):
d = os.path.normpath(os.path.join(os.path.dirname(CONFIG_PATH), "..", d))
return d
@@ -1363,8 +1412,8 @@ def _write_all_static() -> None:
db_images = []
archived_count = 0
for p in persons:
fpath = os.path.join(output_dir, p[0])
if not os.path.exists(fpath):
exists, mtime = _get_cached_file_meta(p[0], output_dir)
if not exists:
continue
is_archived = bool(p[14]) if p[14] else False
@@ -1406,7 +1455,7 @@ def _write_all_static() -> None:
print(f"[static] write_all: {len(db_images)} total images, {archived_count} archived")
try:
db_images.sort(
key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])),
key=lambda x: _get_cached_file_meta(x["filename"], output_dir)[1],
reverse=True,
)
except Exception:
@@ -1705,6 +1754,7 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
except Exception as db_err:
print(f"Database error in batch worker: {db_err}")
jobs[job_id]["latest_output"] = out_name
jobs[job_id]["done"] += 1
# Regenerate static JSON so the frontend's polling picks up the
# new image immediately (progressive refresh, not just at the end).
@@ -1806,6 +1856,7 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
except Exception as db_err:
print(f"DB error in multi-ref: {db_err}")
jobs[job_id]["latest_output"] = out_name
jobs[job_id]["done"] += 1
# Regenerate static JSON so the frontend's polling picks up the new
# image immediately (progressive refresh, matching _batch_worker).
@@ -2044,8 +2095,8 @@ def list_images(archived: bool = False):
# source_refs, has_clothing, content_type, faceswap_source_video, archived
db_images = []
for p in persons:
fpath = os.path.join(output_dir, p[0])
if not os.path.exists(fpath):
exists, mtime = _get_cached_file_meta(p[0], output_dir)
if not exists:
continue
db_images.append({
"filename": p[0],
@@ -2065,7 +2116,7 @@ def list_images(archived: bool = False):
"archived": bool(p[14]) if p[14] else False,
})
db_images.sort(
key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])),
key=lambda x: _get_cached_file_meta(x["filename"], output_dir)[1],
reverse=True,
)
return {"images": db_images}
@@ -2079,7 +2130,7 @@ def list_images(archived: bool = False):
if f.lower().endswith(all_extensions)
and not (f.lower().endswith('.jpg') and os.path.splitext(f)[0] in video_stems)
]
files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
files.sort(key=lambda x: _get_cached_file_meta(x, output_dir)[1], reverse=True)
return {"images": [{"filename": f} for f in files]}
except Exception as e:
raise HTTPException(500, str(e))
@@ -2568,7 +2619,7 @@ def merge_groups(req: MergeRequest):
# Write synchronously: the frontend reloads images.json immediately after this
# returns, so an async rebuild would race and show the pre-merge grouping.
_write_all_static()
_invalidate_static()
return {"group_id": gid, "files": req.filenames}
@@ -2584,7 +2635,7 @@ def extract_from_group(req: ExtractRequest):
except Exception as db_err:
print(f"Database error in extract: {db_err}")
_write_all_static()
_invalidate_static()
return {"filename": req.filename}
@@ -2762,7 +2813,7 @@ def set_privacy_lock():
global _privacy_locked
with _privacy_lock:
_privacy_locked = True
_write_all_static()
_invalidate_static()
return {"status": "locked"}
@@ -2771,7 +2822,7 @@ def set_privacy_unlock():
global _privacy_locked
with _privacy_lock:
_privacy_locked = False
_write_all_static()
_invalidate_static()
return {"status": "unlocked"}
@@ -2827,7 +2878,8 @@ def _extract_face_bg(filename: str, fpath: str):
group_id = person[1] if person else None
gid_tag = (group_id or "face").replace("/", "_")
face_fname = f"{gid_tag}_face.png"
face_path = os.path.join(os.path.dirname(fpath), face_fname)
output_dir = _load_output_dir()
face_path = os.path.join(output_dir, face_fname)
cropped.save(face_path)
face_embed = face.normed_embedding.tolist() if hasattr(face, 'normed_embedding') and face.normed_embedding is not None else None
database.upsert_person(face_fname, filepath=face_path, group_id=group_id,
@@ -2837,6 +2889,7 @@ def _extract_face_bg(filename: str, fpath: str):
hidden=True,
tags=["FACE"])
print(f"[extract-face] saved {face_fname}" + (" + face embedding" if face_embed else ""))
_invalidate_static()
except Exception as e:
print(f"[extract-face] error for {filename}: {e}")
@@ -2933,6 +2986,9 @@ def upload_image(
shutil.copyfileobj(image.file, f)
# Fast path: add to existing group without pose generation
if not group_id:
group_id = naming.get_base_name(filename)
if group_id and skip_poses:
sort_order = database.get_next_sort_order(group_id)
database.upsert_person(filename, filepath=file_path, group_id=group_id,
@@ -3055,8 +3111,9 @@ def set_image_preferred(filename: str):
others = [r[0] for r in rows if r[0] != filename]
database.set_group_order(group_id, [filename] + others)
_invalidate_static()
fpath = os.path.join(_load_output_dir(), filename)
if os.path.exists(fpath):
# Use stored filepath if available (absolute), else resolve relative to output_dir
fpath = person[5] if (len(person) > 5 and person[5]) else os.path.join(_load_output_dir(), filename)
if fpath and os.path.exists(fpath):
_face_executor.submit(_extract_face_bg, filename, fpath)
return {"filename": filename, "group_id": group_id}
@@ -3064,9 +3121,9 @@ def set_image_preferred(filename: str):
@app.post("/images/{filename:path}/extract-face")
def extract_face_endpoint(filename: str):
"""Detect and crop the largest face from image; saves as {group_id}_face.png."""
output_dir = _load_output_dir()
fpath = os.path.join(output_dir, filename)
if not os.path.exists(fpath):
person = database.get_person(filename)
fpath = person[5] if (person and len(person) > 5 and person[5]) else os.path.join(_load_output_dir(), filename)
if not fpath or not os.path.exists(fpath):
raise HTTPException(404, "not found")
_face_executor.submit(_extract_face_bg, filename, fpath)
return {"status": "queued", "filename": filename}
@@ -3506,6 +3563,7 @@ def delete_image(filename: str):
_move_to_trash(person[5])
database.delete_person(filename)
_update_cached_file_meta(filename, exists=False)
_invalidate_static()
return {"status": "deleted", "filename": filename}
@@ -3563,6 +3621,7 @@ def delete_group(group_id: str):
for filename, filepath in files:
if filepath and os.path.exists(filepath):
_move_to_trash(filepath)
_update_cached_file_meta(filename, exists=False)
database.delete_group(group_id)
_invalidate_static()
@@ -3748,6 +3807,7 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
source_refs=json.dumps(refs))
except Exception as db_err:
print(f"[scenery] DB error: {db_err}")
jobs[job_id]["latest_output"] = out_name
jobs[job_id]["status"] = "done"
jobs[job_id]["output"] = out_name
# Regenerate the static JSON so the frontend's polling surfaces the new
@@ -4182,11 +4242,27 @@ def remove_background_sam(filename: str):
f.write(transparent_png)
# Register sidecar in DB so it appears in the same group
group_id = person[1]
database.upsert_person(nobg_filename, filepath=nobg_path,
group_id=group_id, has_background=False,
source_refs=json.dumps([filename]))
group_id = person[1] if person and person[1] else naming.get_base_name(os.path.basename(filename))
tags_list = None
if person[2]:
try:
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
except Exception:
tags_list = None
database.upsert_person(
nobg_filename, filepath=nobg_path, group_id=group_id,
name=person[0], tags=tags_list, embedding=person[3],
clip_description=person[4], prompt=person[6], pose=person[7],
group_name=person[9], hidden=person[10],
has_background=False,
has_clothing=person[13],
is_source=person[14],
pose_description=person[15],
pose_skeleton=person[16],
source_refs=json.dumps([filename]), # original is the reference
)
_invalidate_static()
used_sam2 = _sam2_predictor is not False and _sam2_predictor is not None
return {
"status": "success",
@@ -4258,10 +4334,23 @@ def manual_crop_image(filename: str, req: CropRequest):
new_filename = new_basename
path = os.path.join(output_dir, new_basename)
shutil.copy2(src_path, path)
group_id = person[1] if person and person[1] else naming.get_base_name(os.path.basename(filename))
tags_list = None
if person[2]:
try:
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
except Exception:
tags_list = None
database.upsert_person(
new_filename, filepath=path, group_id=person[1],
prompt=person[6], pose=person[7],
has_background=person[11], has_clothing=person[13],
new_filename, filepath=path, group_id=group_id,
name=person[0], tags=tags_list, embedding=person[3],
clip_description=person[4], prompt=person[6], pose=person[7],
group_name=person[9], hidden=person[10],
has_background=person[11],
has_clothing=person[13],
is_source=person[14],
pose_description=person[15],
pose_skeleton=person[16],
source_refs=json.dumps([filename]), # original is the reference
)
else:
@@ -4376,10 +4465,23 @@ def pad_image(filename: str, req: PadRequest):
new_filename = new_basename
path = os.path.join(output_dir, new_basename)
shutil.copy2(src_path, path)
group_id = person[1] if person and person[1] else naming.get_base_name(os.path.basename(filename))
tags_list = None
if person[2]:
try:
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
except Exception:
tags_list = None
database.upsert_person(
new_filename, filepath=path, group_id=person[1],
prompt=person[6], pose=person[7],
has_background=person[11], has_clothing=person[13],
new_filename, filepath=path, group_id=group_id,
name=person[0], tags=tags_list, embedding=person[3],
clip_description=person[4], prompt=person[6], pose=person[7],
group_name=person[9], hidden=person[10],
has_background=person[11],
has_clothing=person[13],
is_source=person[14],
pose_description=person[15],
pose_skeleton=person[16],
source_refs=json.dumps([filename]),
)
else:
@@ -4393,7 +4495,20 @@ def pad_image(filename: str, req: PadRequest):
if req.outpaint:
# Trigger Qwen outpainting
out_instr = "Naturally outpaint and extend the borders of the image to complete the scene."
# If the image is transparent, composite it onto a neutral background (black)
# so the AI can see the area to be filled.
qwen_input = padded
fill_desc = ""
if qwen_input.mode == "RGBA":
# Composite onto black for maximal contrast in outpainting
bg = Image.new("RGB", qwen_input.size, (0, 0, 0))
bg.paste(qwen_input, mask=qwen_input.split()[3])
qwen_input = bg
fill_desc = " replacing the black background areas"
elif req.fill in ["black", "white"]:
fill_desc = f" replacing the {req.fill} background areas"
out_instr = f"Naturally outpaint and extend the borders of the image to complete the scene{fill_desc}."
actual_prompt = req.prompt or out_instr
if req.prompt and out_instr.lower() not in actual_prompt.lower():
actual_prompt = f"{actual_prompt}. {out_instr}"
@@ -4401,7 +4516,7 @@ def pad_image(filename: str, req: PadRequest):
actual_prompt = f"{person[6]}. {out_instr}"
try:
png_bytes = _run_pipeline(padded, actual_prompt)
png_bytes = _run_pipeline(qwen_input, actual_prompt)
padded = Image.open(io.BytesIO(png_bytes))
# Register the outpaint prompt and set has_background=True in DB
database.upsert_person(new_filename, prompt=actual_prompt, has_background=True)
@@ -4474,16 +4589,29 @@ def duplicate_image(filename: str):
new_path = os.path.join(output_dir, new_basename)
_shutil.copy2(path, new_path)
group_id = person[1]
# person tuple: (name, group_id, tags, embedding, clip_description, filepath,
# prompt, pose, sort_order, group_name, hidden, has_background, source_refs, has_clothing)
group_id = person[1] if person and person[1] else naming.get_base_name(os.path.basename(filename))
# Parse tags list if present
tags_list = None
if person[2]:
try:
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
except Exception:
tags_list = None
database.upsert_person(
new_filename, filepath=new_path, group_id=group_id,
prompt=person[6], pose=person[7],
name=person[0], tags=tags_list, embedding=person[3],
clip_description=person[4], prompt=person[6], pose=person[7],
group_name=person[9], hidden=person[10],
has_background=person[11],
has_clothing=person[13],
is_source=person[14],
pose_description=person[15],
pose_skeleton=person[16],
source_refs=json.dumps([filename]), # original is the reference
)
_update_cached_file_meta(new_filename, exists=True)
_invalidate_static()
return {"status": "success", "new_filename": new_filename, "new_url": f"/output/{new_filename}"}
@@ -4503,6 +4631,7 @@ def restore_background(filename: str):
bg.save(buf, format="PNG")
with open(path, "wb") as f:
f.write(buf.getvalue())
_invalidate_static()
return {"status": "success", "filename": filename}

View File

@@ -17,6 +17,10 @@ Anatomically precise, photo realistic, high detail, keep the characteristics of
Masterpiece, you are in a black void, hyper-realistic, high quality. detailed. detailed skin.
# Outpaint legs
Naturally outpaint draw the legs and feet, and arms of both female keeping the full image intacts.
# Feminine
1 female nude.
@@ -36,7 +40,6 @@ Keep characteristics of person in Image 2
replace the female in Image 1 by the teenage female in Image 2. Keep the Identical person in image 2. photo realistic
# Undress
masterpiece, high quality,
@@ -4082,11 +4085,21 @@ The body is fully exposed for inspection.
Eyes looking at the camera, facial characteristics as reference.
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
# Labial Lattice 2hand
seated, legs spread wide, one leg extended forward, the other bent at the knee with foot resting flat.
torso leaning slightly back.
arms extended along the thighs, fingers lightly brushing labia.
head tilted back, eyes closed, a subtle smile on the lips.
legs positioned to create a triangular frame around the genitals.
a sense of vulnerability and openness.
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
# Labial Lattice
seated, legs spread wide, one leg extended forward, the other bent at the knee with foot resting flat.
torso leaning slightly back, supported on hands placed beside hips.
arms extended along the thighs, fingers lightly brushing labia.
torso leaning slightly back, supported on one hand placed beside hips.
one arm extended along the thighs, fingers lightly brushing labia.
head tilted back, eyes closed, a subtle smile on the lips.
legs positioned to create a triangular frame around the genitals.
a sense of vulnerability and openness.

View File

@@ -0,0 +1,394 @@
import os
import sys
import json
import shutil
import unittest
from datetime import datetime as _dt
from PIL import Image
# Ensure tour-comfy is in import path so we can import the FastAPI app and database module
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from fastapi.testclient import TestClient
import database
from edit_api import app, _load_output_dir
class TestAPIRegression(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Determine output directory
cls.output_dir = _load_output_dir()
cls.client = TestClient(app)
# Unique mock identifiers to avoid conflicts
cls.test_ref_filename = "test_regression_ref_image_123.png"
cls.test_other_filename = "test_regression_other_image_123.png"
cls.group_id = "test_regression_group_123"
cls.test_ref_path = os.path.join(cls.output_dir, cls.test_ref_filename)
cls.test_other_path = os.path.join(cls.output_dir, cls.test_other_filename)
# Ensure any leftover artifacts from past failed runs are cleaned
cls._cleanup_database()
cls._cleanup_files()
# Create dummy test files (100x100 pixels, RGB format)
img = Image.new("RGB", (100, 100), color=(255, 0, 0))
img.save(cls.test_ref_path, "PNG")
img_other = Image.new("RGB", (100, 100), color=(0, 255, 0))
img_other.save(cls.test_other_path, "PNG")
# Mock metadata
cls.name = "Regression Test Character"
cls.tags = ["VISIBLE", "LIKE", "21+"]
cls.embedding = [0.1] * 1024
cls.clip_description = "A dummy regression test image description"
cls.prompt = "masterpiece, high quality"
cls.pose = "standing"
cls.group_name = "Regression Group"
cls.hidden = False
cls.has_background = True
cls.has_clothing = False
cls.is_source = True
cls.pose_description = "The model is standing and looking directly at the camera."
cls.pose_skeleton = '{"keypoints": [1, 2, 3]}'
# Insert reference image into the database
database.upsert_person(
cls.test_ref_filename,
filepath=cls.test_ref_path,
name=cls.name,
group_id=cls.group_id,
tags=cls.tags,
embedding=cls.embedding,
clip_description=cls.clip_description,
prompt=cls.prompt,
pose=cls.pose,
sort_order=0,
group_name=cls.group_name,
hidden=cls.hidden,
has_background=cls.has_background,
has_clothing=cls.has_clothing,
is_source=cls.is_source,
pose_description=cls.pose_description,
pose_skeleton=cls.pose_skeleton
)
# Insert second image in same group for reordering test
database.upsert_person(
cls.test_other_filename,
filepath=cls.test_other_path,
name=cls.name,
group_id=cls.group_id,
tags=cls.tags,
embedding=cls.embedding,
clip_description=cls.clip_description,
prompt=cls.prompt,
pose=cls.pose,
sort_order=1,
group_name=cls.group_name,
hidden=cls.hidden,
has_background=cls.has_background,
has_clothing=cls.has_clothing,
is_source=cls.is_source,
pose_description=cls.pose_description,
pose_skeleton=cls.pose_skeleton
)
# Track dynamically created files and database rows for cleanup
cls.created_files = [cls.test_ref_path, cls.test_other_path]
cls.created_db_rows = [cls.test_ref_filename, cls.test_other_filename]
@classmethod
def tearDownClass(cls):
# Cleanup
cls._cleanup_database()
cls._cleanup_files()
@classmethod
def _cleanup_database(cls):
# Safely delete any inserted test rows from the person database table
conn = database.get_db_connection()
cur = conn.cursor()
try:
# Delete any filenames starting with test_regression or containing ts_crop/ts_pad
cur.execute("""
DELETE FROM person
WHERE filename LIKE 'test_regression_%%'
OR filename LIKE '%%_crop_test_regression_%%'
OR filename LIKE '%%_pad_test_regression_%%'
OR group_id = %s
""", (cls.group_id,))
conn.commit()
except Exception as e:
print(f"Error cleaning up database: {e}")
conn.rollback()
finally:
cur.close()
database._put_db_connection(conn)
@classmethod
def _cleanup_files(cls):
# Clean up files matching test patterns
for f in os.listdir(cls.output_dir):
if "test_regression_" in f or "_crop_" in f or "_pad_" in f:
p = os.path.join(cls.output_dir, f)
try:
if os.path.exists(p):
os.remove(p)
except Exception as e:
print(f"Error removing file {p}: {e}")
def assertEmbeddingEqual(self, val1, val2):
if isinstance(val1, str):
val1 = [float(x) for x in val1.strip("[]").split(",")]
if isinstance(val2, str):
val2 = [float(x) for x in val2.strip("[]").split(",")]
self.assertEqual(len(val1), len(val2))
for x, y in zip(val1, val2):
self.assertAlmostEqual(x, y, places=4)
def test_01_duplicate_copies_all_pose_and_meta_details(self):
# Send duplicate request to FastAPI
response = self.client.post(f"/images/{self.test_ref_filename}/duplicate")
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertEqual(res_data["status"], "success")
new_filename = res_data["new_filename"]
self.created_db_rows.append(new_filename)
new_path = os.path.join(self.output_dir, os.path.basename(new_filename))
self.created_files.append(new_path)
# Assert duplicate file exists on disk
self.assertTrue(os.path.exists(new_path), f"Duplicated file {new_path} not found on disk")
# Assert all metadata has been accurately duplicated in DB
person = database.get_person(new_filename)
self.assertIsNotNone(person, "Duplicated database entry not found")
# Column mappings as in database.py get_person:
# SELECT name, group_id, tags, embedding, clip_description, filepath,
# prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
# has_clothing, is_source, pose_description, pose_skeleton
self.assertEqual(person[0], self.name)
self.assertEqual(person[1], self.group_id)
# Tags (assert LIKE and 21+ are preserved)
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
self.assertIn("LIKE", tags_list)
self.assertIn("21+", tags_list)
self.assertIn("VISIBLE", tags_list)
# Verify 21+ is just a standard tag, not a safety blocker
self.assertIn("21+", tags_list)
self.assertEmbeddingEqual(person[3], self.embedding)
self.assertEqual(person[4], self.clip_description)
self.assertEqual(person[5], new_path)
self.assertEqual(person[6], self.prompt)
self.assertEqual(person[7], self.pose)
self.assertEqual(person[9], self.group_name)
self.assertEqual(person[10], self.hidden)
self.assertEqual(person[11], self.has_background)
# source_refs should refer to original
source_refs = json.loads(person[12]) if isinstance(person[12], str) else person[12]
self.assertIn(self.test_ref_filename, source_refs)
self.assertEqual(person[13], self.has_clothing)
self.assertEqual(person[14], self.is_source)
self.assertEqual(person[15], self.pose_description)
self.assertEqual(person[16], self.pose_skeleton)
def test_02_crop_as_copy_copies_all_pose_and_meta_details(self):
# Crop region: (10, 10, 90, 90), as_copy=True
req_payload = {
"x1": 10,
"y1": 10,
"x2": 90,
"y2": 90,
"as_copy": True
}
response = self.client.post(f"/images/{self.test_ref_filename}/crop", json=req_payload)
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertEqual(res_data["status"], "success")
new_filename = res_data["new_filename"]
self.created_db_rows.append(new_filename)
new_path = os.path.join(self.output_dir, os.path.basename(new_filename))
self.created_files.append(new_path)
# Verify physical file existence and crop dimensions (should be 80x80)
self.assertTrue(os.path.exists(new_path), f"Cropped file {new_path} not found on disk")
cropped_img = Image.open(new_path)
self.assertEqual(cropped_img.size, (80, 80))
# Verify database entry has complete metadata
person = database.get_person(new_filename)
self.assertIsNotNone(person, "Cropped copy database entry not found")
self.assertEqual(person[0], self.name)
self.assertEqual(person[1], self.group_id)
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
self.assertIn("LIKE", tags_list)
self.assertIn("21+", tags_list)
self.assertIn("VISIBLE", tags_list)
self.assertEmbeddingEqual(person[3], self.embedding)
self.assertEqual(person[4], self.clip_description)
self.assertEqual(person[5], new_path)
self.assertEqual(person[6], self.prompt)
self.assertEqual(person[7], self.pose)
self.assertEqual(person[9], self.group_name)
self.assertEqual(person[10], self.hidden)
self.assertEqual(person[11], self.has_background)
source_refs = json.loads(person[12]) if isinstance(person[12], str) else person[12]
self.assertIn(self.test_ref_filename, source_refs)
self.assertEqual(person[13], self.has_clothing)
self.assertEqual(person[14], self.is_source)
self.assertEqual(person[15], self.pose_description)
self.assertEqual(person[16], self.pose_skeleton)
def test_03_crop_in_place_keeps_all_meta_information(self):
# Crop region: (20, 20, 80, 80), as_copy=False (in-place)
req_payload = {
"x1": 20,
"y1": 20,
"x2": 80,
"y2": 80,
"as_copy": False
}
# First verify original size is 100x100
orig_img = Image.open(self.test_ref_path)
self.assertEqual(orig_img.size, (100, 100))
response = self.client.post(f"/images/{self.test_ref_filename}/crop", json=req_payload)
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertEqual(res_data["status"], "success")
self.assertEqual(res_data["new_filename"], self.test_ref_filename)
# Verify physical file size updated to 60x60
cropped_img = Image.open(self.test_ref_path)
self.assertEqual(cropped_img.size, (60, 60))
# Verify database entry has complete metadata untouched
person = database.get_person(self.test_ref_filename)
self.assertIsNotNone(person, "Database entry not found after in-place crop")
self.assertEqual(person[0], self.name)
self.assertEqual(person[1], self.group_id)
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
self.assertIn("LIKE", tags_list)
self.assertIn("21+", tags_list)
self.assertEmbeddingEqual(person[3], self.embedding)
self.assertEqual(person[4], self.clip_description)
self.assertEqual(person[6], self.prompt)
self.assertEqual(person[7], self.pose)
self.assertEqual(person[9], self.group_name)
self.assertEqual(person[10], self.hidden)
self.assertEqual(person[11], self.has_background)
self.assertEqual(person[13], self.has_clothing)
self.assertEqual(person[14], self.is_source)
self.assertEqual(person[15], self.pose_description)
self.assertEqual(person[16], self.pose_skeleton)
def test_04_pad_as_copy_copies_all_pose_and_meta_details(self):
# Pad canvas: expand top and bottom by 10 pixels, as_copy=True
req_payload = {
"top": 10,
"right": 0,
"bottom": 10,
"left": 0,
"as_copy": True,
"fill": "transparent",
"outpaint": False
}
# Original size at this point is 60x60 (due to test_03 crop)
response = self.client.post(f"/images/{self.test_ref_filename}/pad", json=req_payload)
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertEqual(res_data["status"], "success")
new_filename = res_data["new_filename"]
self.created_db_rows.append(new_filename)
new_path = os.path.join(self.output_dir, os.path.basename(new_filename))
self.created_files.append(new_path)
# Verify physical file existence and pad dimensions (should be 60x80)
self.assertTrue(os.path.exists(new_path), f"Padded file {new_path} not found on disk")
padded_img = Image.open(new_path)
self.assertEqual(padded_img.size, (60, 80))
# Verify database entry has complete metadata
person = database.get_person(new_filename)
self.assertIsNotNone(person, "Padded copy database entry not found")
self.assertEqual(person[0], self.name)
self.assertEqual(person[1], self.group_id)
tags_list = json.loads(person[2]) if isinstance(person[2], str) else person[2]
self.assertIn("LIKE", tags_list)
self.assertIn("21+", tags_list)
self.assertIn("VISIBLE", tags_list)
self.assertEmbeddingEqual(person[3], self.embedding)
self.assertEqual(person[4], self.clip_description)
self.assertEqual(person[5], new_path)
self.assertEqual(person[6], self.prompt)
self.assertEqual(person[7], self.pose)
self.assertEqual(person[9], self.group_name)
self.assertEqual(person[10], self.hidden)
self.assertEqual(person[11], self.has_background)
source_refs = json.loads(person[12]) if isinstance(person[12], str) else person[12]
self.assertIn(self.test_ref_filename, source_refs)
self.assertEqual(person[13], self.has_clothing)
self.assertEqual(person[14], self.is_source)
self.assertEqual(person[15], self.pose_description)
self.assertEqual(person[16], self.pose_skeleton)
def test_05_reorder_group_updates_sort_orders(self):
# Verify original order
order_rows_before = database.get_group_order(self.group_id)
filenames_before = [r[0] for r in order_rows_before]
self.assertIn(self.test_ref_filename, filenames_before)
self.assertIn(self.test_other_filename, filenames_before)
# Reverse the order of files and submit
new_order = [self.test_other_filename, self.test_ref_filename]
req_payload = {
"filenames": new_order
}
response = self.client.post(f"/groups/{self.group_id}/order", json=req_payload)
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertEqual(res_data["group_id"], self.group_id)
self.assertEqual(res_data["filenames"], new_order)
# Get order from database and verify state reflects the updates
order_rows_after = database.get_group_order(self.group_id)
filenames_after = [r[0] for r in order_rows_after]
# Verify custom sort orders are explicitly 0, 1
self.assertEqual(filenames_after[:2], new_order)
person_other = database.get_person(self.test_other_filename)
person_ref = database.get_person(self.test_ref_filename)
# Column 8 in database.get_person is sort_order
self.assertEqual(person_other[8], 0)
self.assertEqual(person_ref[8], 1)
if __name__ == "__main__":
unittest.main()