From 3b9b9e829b3643733693e46c80102c9b65ee2fc0 Mon Sep 17 00:00:00 2001 From: mike Date: Sun, 28 Jun 2026 16:56:07 +0200 Subject: [PATCH] =?UTF-8?q?=20=20=20Summary=20=20=20=20=E2=80=A2=20Impleme?= =?UTF-8?q?nted=20global=20offline=20mode=20for=20all=20HuggingFace-depend?= =?UTF-8?q?ent=20modules=20to=20eliminate=20unauthenticated=20request=20wa?= =?UTF-8?q?rnings=20and=20prevent=20external=20connection=20attempts=20dur?= =?UTF-8?q?ing=20startup=20and=20runtime.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 66 ++++- backlog.md | 2 +- tour-comfy/car.html | 432 +++++++++++++++++------------- tour-comfy/edit_api.py | 207 +++++++++++--- tour-comfy/poses.md | 19 +- tour-comfy/test_regression_api.py | 394 +++++++++++++++++++++++++++ 6 files changed, 881 insertions(+), 239 deletions(-) create mode 100644 tour-comfy/test_regression_api.py diff --git a/AGENTS.md b/AGENTS.md index c444399..dd86139 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) diff --git a/backlog.md b/backlog.md index 3443468..db148d2 100644 --- a/backlog.md +++ b/backlog.md @@ -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 diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 57aff08..b187c1c 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -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,9 +9362,12 @@ const group = groupData.get(lbCurrentGid); if (group) { - group.urls.splice(lbIdx, 0, lbUrls[lbIdx]); - group.names.splice(lbIdx, 0, lbNames[lbIdx]); - group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + 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(); @@ -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'); + fileHasBg[fname] = false; + _bustStudioImage(fname); + toggleCheckerboard(true); 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; - toggleCheckerboard(true); - }; - preload.src = nobgUrl; } } 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); } } diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py index 45ee612..d25461c 100644 --- a/tour-comfy/edit_api.py +++ b/tour-comfy/edit_api.py @@ -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} diff --git a/tour-comfy/poses.md b/tour-comfy/poses.md index 633ddcc..8bbf3b5 100644 --- a/tour-comfy/poses.md +++ b/tour-comfy/poses.md @@ -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. diff --git a/tour-comfy/test_regression_api.py b/tour-comfy/test_regression_api.py new file mode 100644 index 0000000..6e1e765 --- /dev/null +++ b/tour-comfy/test_regression_api.py @@ -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()