Compare commits

...

4 Commits

Author SHA1 Message Date
mike
6ad11fc6c0 reorder 2026-06-28 02:29:31 +02:00
mike
3f91694491 reorder 2026-06-28 00:51:07 +02:00
mike
19e0656ccb reorder 2026-06-28 00:23:48 +02:00
mike
03b754112c reorder 2026-06-28 00:09:26 +02:00
5 changed files with 649 additions and 145 deletions

159
AGENTS.md
View File

@@ -63,42 +63,122 @@ FastAPI :8500 (edit_api.py)
- **Systemd**: `comfyui-api` - **Systemd**: `comfyui-api`
- **DB**: PostgreSQL at `192.168.1.160:5433`, database `dv`, table `person` - **DB**: PostgreSQL at `192.168.1.160:5433`, database `dv`, table `person`
#### Core Endpoints #### Endpoints Library
##### Core Generation & Upload
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/upload` | Upload image; optionally add to existing group (`group_id` + `skip_poses`) |
| `POST` | `/batch` | Queue multi-prompt generation job (async) |
| `GET` | `/batch/{job_id}` | Poll batch job status / per-item progress |
| `DELETE` | `/batch/{job_id}` | Cancel active batch job |
| `POST` | `/multi-ref` | Queue a generation job with multiple reference images |
| `POST` | `/refine-prompt` | Refine a generation prompt using the external uncensored chat LLM |
| `POST` | `/edit` | Edit uploaded image |
| `POST` | `/generate-scenery` | Generate scenery background based on video frame + reference image |
| `GET` | `/scenery/library` | Return all scenery images grouped by source video reference |
##### Image Editing & Canvas Expansion (Padding & Crop)
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/images/{filename}/crop` | Crop to pixel rect; `as_copy:true` crops a referenced copy instead of modifying in-place |
| `POST` | `/images/{filename}/autocrop` | Auto-trim transparent borders in-place |
| `POST` | `/images/{filename}/pad` | Expand image canvas with custom padding on each side, optional background fills (`transparent`, `black`, `white`), and optional Qwen-based Outpainting (`outpaint:true`) |
| `POST` | `/images/{filename}/rotate` | Rotate clockwise in 90° steps (lossless transpose, in-place) |
| `POST` | `/images/{filename}/invert-alpha` | Invert the alpha channel (recovers background removal errors) |
| `POST` | `/images/{filename}/duplicate` | Copy image into the same group with timestamp-based filename and `source_refs=[original]` |
| `POST` | `/restore-background/{filename}` | Flatten RGBA to opaque RGB via white composite background |
##### Background Removal
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/remove-background/{filename}` | rembg BG removal, overwrites in-place; sets `has_background=false` + refreshes static data |
| `POST` | `/remove-background-sam/{filename}` | SAM2 BG removal → `.nobg.png` sidecar |
| `POST` | `/remove-background/group/{group_id}` | Queue background removal for all group members as an async background task |
| `GET` | `/sam2/check` | Return whether SAM2 background remover is available |
##### Metadata, Custom Labels & Group Management
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| `GET` | `/images` | List all images with DB metadata | | `GET` | `/images` | List all images with DB metadata |
| `GET` | `/images/{filename}` | Serve image file | | `GET` | `/images/{filename}` | Serve image file |
| `POST` | `/upload` | Upload image; optionally add to existing group (`group_id` + `skip_poses`) |
| `POST` | `/batch` | Queue multi-prompt generation job (async) |
| `GET` | `/jobs/{id}` | Poll batch job status / per-item progress |
| `POST` | `/images/{filename}/set-preferred` | Move to group slot 0; triggers face extraction background task |
| `POST` | `/images/{filename}/extract-face` | Extract & save face crop via insightface (background task) |
| `POST` | `/images/{filename}/archive` | Soft-archive (hidden from default view) |
| `POST` | `/images/{filename}/unarchive` | Restore archived image |
| `POST` | `/images/{filename}/set-hidden` | Toggle studio visibility |
| `DELETE` | `/images/{filename}` | Delete image + DB record |
| `POST` | `/images/{filename}/crop` | Crop to pixel rect; `as_copy:true` crops a referenced copy instead of in-place |
| `POST` | `/images/{filename}/autocrop` | Auto-trim transparent border |
| `POST` | `/images/{filename}/rotate` | Rotate clockwise in 90° steps (lossless transpose, in-place) |
| `POST` | `/images/{filename}/invert-alpha` | Invert the alpha channel (fixes wrong-segment BG removal) |
| `POST` | `/images/{filename}/duplicate` | Copy into same group with `source_refs=[original]` |
| `POST` | `/faceswap` | insightface video faceswap |
| `POST` | `/remove-background/{filename}` | rembg BG removal, overwrites in-place; sets `has_background=false` + refreshes static data |
| `POST` | `/remove-background-sam/{filename}` | SAM2 BG removal → `.nobg.png` sidecar |
| `GET` | `/wireframe/frame/{video_name}` | Extract frame at `?t=` (01) from wireframe video |
| `GET` | `/groups` | List all groups with members | | `GET` | `/groups` | List all groups with members |
| `GET` | `/group-names` | Return map of custom group names |
| `POST` | `/group-names/{group_id}` | Save custom group name |
| `POST` | `/groups/merge` | Merge several groups into one group |
| `POST` | `/groups/extract` | Extract a file into its own solo group |
| `GET` | `/groups/{group_id}/order` | Return order of members in a group |
| `POST` | `/groups/{group_id}/order` | Set custom sort order of group members |
| `GET` | `/similar/{filename}` | Find visually similar images using database embeddings |
| `GET` | `/names` | Map `filename → person name` | | `GET` | `/names` | Map `filename → person name` |
| `GET/POST` | `/config` | Read / write `config.json` | | `POST` | `/names/{filename}` | Map image file to custom character name |
| `GET` | `/poses` | Load pose library from `poses.md` |
| `POST` | `/poses` | Create / update / rename a pose (`old_name` to rename) → rewrites `poses.md` | ##### Visibility, Archiving & Deletion
| `DELETE` | `/poses/{name}` | Delete a pose from `poses.md` | | Method | Path | Purpose |
| `GET` | `/pose/check` | Report pose-estimator availability + backend | |--------|------|---------|
| `POST` | `/images/{filename}/hidden` | Toggle studio visibility (soft-hide) |
| `POST` | `/images/{filename}/archive` | Soft-archive image (hidden from default view) |
| `POST` | `/images/{filename}/unarchive` | Restore soft-archived image |
| `DELETE` | `/images/{filename}` | Delete image file + DB record |
| `POST` | `/groups/{group_id}/archive` | Soft-archive an entire group |
| `POST` | `/groups/{group_id}/unarchive` | Restore an archived group |
| `DELETE` | `/groups/{group_id}` | Delete entire group files and DB records |
##### 2D Body Pose Tools (rtmlib / RTMPose)
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/pose/check` | Report pose-estimator availability + backend (rtmlib or mediapipe) |
| `POST` | `/images/{filename}/pose` | Estimate COCO-17 body keypoints (caches descriptor for similar-search) | | `POST` | `/images/{filename}/pose` | Estimate COCO-17 body keypoints (caches descriptor for similar-search) |
| `GET` | `/pose/similar/{filename}` | Rank library images by pose similarity to this image | | `GET` | `/pose/similar/{filename}` | Rank library images by pose similarity to this image |
| `POST` | `/pose/similar` | Rank library images by similarity to a supplied (edited) skeleton | | `POST` | `/pose/similar` | Rank library images by similarity to a supplied (edited) skeleton |
| `POST` | `/pose/index` | Build pose descriptors for the whole library (daemon thread) | | `POST` | `/pose/index` | Build pose descriptors for the whole library (daemon thread) |
| `GET` | `/pose/index/status` | Poll pose-index build progress | | `GET` | `/pose/index/status` | Poll pose-index build progress |
| `POST` | `/pose/from-wireframe` | Extract frame from a wireframe video and estimate body pose keypoints |
| `POST` | `/pose/render` | Render keypoints as OpenPose skeleton image (base64) |
| `POST` | `/generate-with-pose` | Generate image from a specific pose skeleton (avoids wireframe bleed-through) |
| `GET` | `/poses` | Load pose library from `poses.md` |
| `POST` | `/poses` | Create / update / rename a pose (`old_name` to rename) → rewrites `poses.md` |
| `DELETE` | `/poses/{name}` | Delete a pose from `poses.md` |
##### 3D Turntable & Orbit Previews
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/orbit` | Queue an orbit preview generation. Engine `depth` (fast 2.5D card parallax) or `qwen` (turntable) |
| `GET` | `/turntable/status` | Return background turntable generation state/progress summary |
| `GET` | `/turntable/status/{group_id}` | Return turntable state for a specific group |
| `POST` | `/turntable/pause` | Pause background turntable generation |
| `POST` | `/turntable/resume` | Resume background turntable generation |
| `DELETE` | `/turntable/{group_id}` | Reset turntable cache & records for group to force re-generation |
##### Face Processing (insightface)
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/faceswap` | insightface video faceswap |
| `GET` | `/faceswap/check` | Report available face enhancement backends |
| `POST` | `/images/{filename}/extract-face` | Extract & save face crop via insightface (background task) |
| `POST` | `/faces/similar` | Find groups with visually similar faces using embeddings |
| `POST` | `/faces/index` | Build face embedding index for all preferred faces in library |
| `GET` | `/faces/index/status` | Poll face-indexing progress status |
| `GET` | `/faces/{group_id}` | Check if face crop exists for group |
##### Videos & Wireframe Guides
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/videos` | List all available wireframe guide videos |
| `GET` | `/wireframe/frame/{video_name}` | Extract frame at `?t=` (01) from wireframe video |
| `GET` | `/wireframe/duration/{video_name}` | Get video duration (seconds) via ffprobe |
| `POST` | `/wireframe/trim` | Trim a wireframe video to given start/end offsets |
| `POST` | `/wireframe/frame` | Extract frame at timestamp, returns base64 PNG |
| `POST` | `/generate-video` | Stitch list of output images into a short looping MP4 video |
##### Database, Config & System Diagnostics
| Method | Path | Purpose |
|--------|------|---------|
| `GET/POST` | `/config` | Read / write `config.json` |
| `GET` | `/db/inconsistencies` | Retrieve consistency report or run a check with `?run_now=true` |
| `POST` | `/db/repair` | Run repair actions (restore, import file, assign group, permanently delete) |
| `POST` | `/db/cleanup` | Delete DB records of files that are missing on disk |
| `GET` | `/health` | API health check status |
--- ---
@@ -111,6 +191,10 @@ prompt
│ YES → strip keywords from Qwen prompt (keeps `is_transparent` flag) │ YES → strip keywords from Qwen prompt (keeps `is_transparent` flag)
│ set negative: "deformed anatomy, watermark, logo" │ 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."
├─ Upload reference image(s) to ComfyUI /upload/image ├─ Upload reference image(s) to ComfyUI /upload/image
│ image1 = source image │ image1 = source image
│ image2 = wireframe pose guide frame (optional) │ image2 = wireframe pose guide frame (optional)
@@ -229,6 +313,8 @@ Model: `buffalo_l`
| source_refs | TEXT (JSON) | Original filenames this was derived from | | source_refs | TEXT (JSON) | Original filenames this was derived from |
| content_type | TEXT | `image` or `video` | | content_type | TEXT | `image` or `video` |
| faceswap_source_video | TEXT | Source video for faceswapped clips | | 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 |
--- ---
@@ -239,17 +325,33 @@ Single-page application (~5000 lines). No build step — pure HTML/CSS/JS.
**Modes**: **Modes**:
- **Gallery** — grid of all groups; click to open Studio - **Gallery** — grid of all groups; click to open Studio
- **Studio** — filmstrip + large viewer + sidebar tabs - **Studio** — filmstrip + large viewer + sidebar tabs
- **Database Consistency** (`trash.html` SPA) — dedicated panel for identifying legacy/orphaned records, untracked files, and manual restore/delete of archived assets
**Sidebar tabs**: **Sidebar tabs**:
| Tab | Purpose | | Tab | Purpose |
|-----|---------| |-----|---------|
| Generate | Camera angle picker, pose selector (with inline edit/add/delete → `poses.md`), wireframe guide, auto-growing prompt override with history autocomplete, fine-tune textareas, multi-ref thumbnails (click to deselect), batch submit | | Generate | Camera angle picker, pose selector (with inline edit/add/delete → `poses.md`), wireframe guide, auto-growing prompt override with history autocomplete, fine-tune textareas, multi-ref thumbnails (click to deselect), batch submit |
| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete, manual crop (copy or in-place), rotate ±90°, No-BG / Invert-α, duplicate, Pose preview (draggable + similar-pose search) | | Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete, manual crop/expand padding (copy or in-place), rotate ±90°, No-BG / Invert-α, duplicate, Pose preview (draggable + similar-pose search) |
| Faceswap | Source face selector, target video, preview quality toggle | | Faceswap | Source face selector, target video, preview quality toggle |
| Segment | SAM2 and rembg BG removal buttons | | Segment | SAM2 and rembg BG removal buttons |
| Scenery | Scenery video reference for generation | | Scenery | Scenery video reference for generation |
### Key Studio Features
#### Canvas Expansion & Manual Padding (Interactive Tooling)
The **Info** sidebar and dedicated toolbar provide professional, reactive manual padding controls:
- **Percent/Pixel Harmony**: Seamlessly accepts both absolute integer pixel values (e.g., `100px`) and percentage-based inputs (e.g., `10%` or `0.1`), ensuring perfect formatting and suffix preservation.
- **Quick Adjustment Presets**: Rapidly adjust margins with single-click actions for `+10%`, `+20%`, `+50%`, `+100px`, or `Reset` to clear all.
- **Uniform Locking**: Enabling the **Uniform** option synchronizes changes on any single padding field to all four sides simultaneously.
- **Intelligent Previews**: Dynamically renders real-time color-coded canvas border previews based on the selected fill type (Amber dashed for `transparent`, translucent black for `black`, and translucent white for `white`).
- **Shortcuts**: Confirm cropping or padding instantly with `Enter`, or dismiss/cancel the operation using `Escape`.
#### Reactive Layout Optimization
To prevent desyncs during image transitions, crops, canvas expansion, or 2.2x zooming:
- A `ResizeObserver` is registered on the primary studio viewer image (`lbImg`).
- Automatically triggers a layout and geometry recalculation, cleanly and immediately adjusting the background transparency checkerboard grid and padding visual overlays to match the actual visual boundaries of the contained image.
**Key state variables**: **Key state variables**:
- `lbCurrentGid` — active group ID - `lbCurrentGid` — active group ID
- `lbIdx` — current filmstrip position - `lbIdx` — current filmstrip position
@@ -304,15 +406,16 @@ skeleton). Results render as a thumbnail strip; clicking one opens that image's
``` ```
qwen-image-edit-rapid-aio-nsfw-v23/ qwen-image-edit-rapid-aio-nsfw-v23/
├── tour-comfy/ ├── tour-comfy/
│ ├── car.html # frontend SPA │ ├── car.html # frontend SPA (Studio Interface)
│ ├── trash.html # frontend Database Consistency Panel SPA
│ ├── edit_api.py # FastAPI backend │ ├── edit_api.py # FastAPI backend
│ ├── database.py # PostgreSQL helpers │ ├── database.py # PostgreSQL helpers
│ ├── watcher.py # output dir auto-processor │ ├── watcher.py # output dir auto-processor
│ ├── config.json # output_dir, comfy_url, etc. │ ├── config.json # output_dir, comfy_url, etc.
│ └── workflow_qwen_edit.json # ComfyUI graph template │ └── workflow_qwen_edit.json # ComfyUI graph template
├── a6000-comfy/ # ComfyUI install (symlinked to ~/comfyui) ├── a6000-comfy/ # ComfyUI install (symlinked to ~/comfyui)
├── AGENTS.md # this file ├── AGENTS.md # this file (System Documentation)
├── architecture2.svg # system architecture diagram ├── ARCHETECTURE.svg # system architecture diagram
├── backlog.md # feature backlog ├── backlog.md # feature backlog
└── requirements.txt # Python deps └── requirements.txt # Python deps
``` ```

View File

@@ -61,7 +61,7 @@ Scenery:
- allow re-ordering of the images in the orbit generated frames - allow re-ordering of the images in the orbit generated frames
- sometimes the UI renders two orbits in the same orbitFullViewer, must be a timer not being cleared in the ui - sometimes the UI renders two orbits in the same orbitFullViewer, must be a timer not being cleared in the ui
- The UI wants to car.html:2817 GET file:///mnt/zim/tour-comfy/output/_turntable/up_34e48ffb/turntable.jpg?t=1782562827857 net::ERR_FILE_NOT_FOUND, often want to show the wireframe - pose - The UI wants to car.html:2817 GET file:///mnt/zim/tour-comfy/output/_turntable/up_34e48ffb/turntable.jpg?t=1782562827857 net::ERR_FILE_NOT_FOUND, often want to show the wireframe - pose
- Jun 27 14:38:03 MIKE bash[3607206]: INFO: 127.0.0.1:53442 - "GET /output/_turntable/up_a1104f04/views/view_023_345deg.png HTTP/1.1" 304 Not Modified those files should be statically served from filesystem, not via server - ✅ Fixed: statically serve turntable and orbit preview files from filesystem, not via server (avoid GET /output/... 304 Not Modified log spam)
- Check face similarity in a group of images. - Check face similarity in a group of images.
- implement a tag-system for images, will be used to filter, group, sort and search images. Obvious tags are VISIBLE, ARCHIVED, LIKE, DISLIKE, RATED, GROUP_ANCHOR, GROUP_MEMBER, BACKGROUND etc.. - implement a tag-system for images, will be used to filter, group, sort and search images. Obvious tags are VISIBLE, ARCHIVED, LIKE, DISLIKE, RATED, GROUP_ANCHOR, GROUP_MEMBER, BACKGROUND etc..
- mark image as source - mark image as source

View File

@@ -169,10 +169,10 @@
.pad-preview-overlay { .pad-preview-overlay {
position: absolute; position: absolute;
background: rgba(245,158,11,0.25); background: rgba(245,158,11,0.4);
border: 1px dashed #f59e0b; border: 1px dashed #f59e0b;
pointer-events: none; pointer-events: none;
z-index: 5; z-index: 10;
} }
.action-btn { .action-btn {
@@ -1323,6 +1323,9 @@
position: relative; height: 100%; border-radius: 6px; overflow: hidden; position: relative; height: 100%; border-radius: 6px; overflow: hidden;
} }
.studio-viewer.show-checker { .studio-viewer.show-checker {
background-color: transparent;
}
#studioCheckerboard {
background-color: #888; background-color: #888;
background-image: background-image:
linear-gradient(45deg,#555 25%,transparent 25%), linear-gradient(45deg,#555 25%,transparent 25%),
@@ -1331,11 +1334,13 @@
linear-gradient(-45deg,transparent 75%,#555 75%); linear-gradient(-45deg,transparent 75%,#555 75%);
background-size: 16px 16px; background-size: 16px 16px;
background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-position: 0 0, 0 8px, 8px -8px, -8px 0;
z-index: 1;
} }
#lbImg { #lbImg {
max-width: 100%; max-height: 100%; max-width: 100%; max-height: 100%;
object-fit: contain; border-radius: 4px; object-fit: contain; border-radius: 4px;
cursor: zoom-in; transition: transform 0.25s; display: block; cursor: zoom-in; transition: transform 0.25s; display: block;
z-index: 2; position: relative;
} }
#lbImg.zoomed { cursor: zoom-out; transform: scale(2.2); transform-origin: center; } #lbImg.zoomed { cursor: zoom-out; transform: scale(2.2); transform-origin: center; }
#lbVideo { max-width: 100%; max-height: 100%; border-radius: 4px; } #lbVideo { max-width: 100%; max-height: 100%; border-radius: 4px; }
@@ -1960,6 +1965,7 @@
onblur="updateGroupName()" onkeydown="if(event.key==='Enter')this.blur()" onblur="updateGroupName()" onkeydown="if(event.key==='Enter')this.blur()"
title="Group name (applies to all images in this group)" /> title="Group name (applies to all images in this group)" />
<span id="lbPoseTag" style="font-size:11px;color:#f59e0b;display:none"></span> <span id="lbPoseTag" style="font-size:11px;color:#f59e0b;display:none"></span>
<span id="lbImgDims" style="font-size:11px;color:#444;margin:0 8px"></span>
<span id="lbCounter" style="font-size:12px;color:#555"></span> <span id="lbCounter" style="font-size:12px;color:#555"></span>
</div> </div>
<div class="studio-topbar-right"> <div class="studio-topbar-right">
@@ -1975,6 +1981,7 @@
<div class="studio-viewer-row"> <div class="studio-viewer-row">
<button class="studio-nav-btn" id="lbPrev" onclick="lbNav(-1)"></button> <button class="studio-nav-btn" id="lbPrev" onclick="lbNav(-1)"></button>
<div class="studio-viewer" id="studioViewer"> <div class="studio-viewer" id="studioViewer">
<div id="studioCheckerboard" style="display:none;position:absolute;pointer-events:none;z-index:1"></div>
<img id="lbImg" src="" alt="" onclick="toggleImageZoom(this)" /> <img id="lbImg" src="" alt="" onclick="toggleImageZoom(this)" />
<video id="lbVideo" style="display:none;max-width:100%;max-height:100%;border-radius:4px" controls autoplay muted loop></video> <video id="lbVideo" style="display:none;max-width:100%;max-height:100%;border-radius:4px" controls autoplay muted loop></video>
<div class="lb-hidden-badge">Hidden from preview</div> <div class="lb-hidden-badge">Hidden from preview</div>
@@ -2051,7 +2058,7 @@
<button class="sb-btn" id="lbCropBtn" style="display:none" onclick="lbAutoCrop()" title="Crop away transparent border">Crop</button> <button class="sb-btn" id="lbCropBtn" style="display:none" onclick="lbAutoCrop()" title="Crop away transparent border">Crop</button>
<button class="sb-btn" id="lbDuplicateBtn" onclick="lbDuplicate()" title="Duplicate image into same group">Duplicate</button> <button class="sb-btn" id="lbDuplicateBtn" onclick="lbDuplicate()" title="Duplicate image into same group">Duplicate</button>
<button class="sb-btn" id="lbManualCropBtn" onclick="startManualCrop()" title="Drag to crop image">Crop…</button> <button class="sb-btn" id="lbManualCropBtn" onclick="startManualCrop()" title="Drag to crop image">Crop…</button>
<button class="sb-btn" id="lbManualPadBtn" onclick="startManualPad()" title="Expand canvas by padding each side">Pad…</button> <button class="sb-btn" id="lbManualPadBtn" onclick="startManualPad()" title="Expand canvas by padding each side">Expand…</button>
<button class="sb-btn" id="lbRotateLeftBtn" onclick="lbRotate(-90)" title="Rotate 90° counter-clockwise">⟲ 90°</button> <button class="sb-btn" id="lbRotateLeftBtn" onclick="lbRotate(-90)" title="Rotate 90° counter-clockwise">⟲ 90°</button>
<button class="sb-btn" id="lbRotateRightBtn" onclick="lbRotate(90)" title="Rotate 90° clockwise">⟳ 90°</button> <button class="sb-btn" id="lbRotateRightBtn" onclick="lbRotate(90)" title="Rotate 90° clockwise">⟳ 90°</button>
<button class="sb-btn" id="lbPoseBtn" onclick="lbTogglePose()" title="Preview body pose skeleton">Pose</button> <button class="sb-btn" id="lbPoseBtn" onclick="lbTogglePose()" title="Preview body pose skeleton">Pose</button>
@@ -2220,8 +2227,86 @@
// --- HYDRATION_START --- // --- HYDRATION_START ---
const PRELOADED_IMAGES = [ const PRELOADED_IMAGES = [
"20260627_232843_pad_20260626_035101_pad_20260626_034830_jb.nobg.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_015832_pad_20260628_015651_img_12.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",
"cg_809653a0_face.png",
"bb_01.png_face.png",
"_turntable/up_aa1add4d/views/view_023_345deg.png",
"_turntable/up_aa1add4d/views/view_003_045deg.png",
"_turntable/up_aa1add4d/views/view_001_015deg.png",
"_turntable/up_7b6eaabb/views/view_001_015deg.png",
"_turntable/up_7b6eaabb/views/view_000_000deg.png",
"_turntable/up_6ed4293c/views/view_023_345deg.png",
"_turntable/up_6ed4293c/views/view_022_330deg.png",
"_turntable/up_6ed4293c/views/view_021_315deg.png",
"_turntable/up_6ed4293c/views/view_020_300deg.png",
"_turntable/up_6ed4293c/views/view_019_285deg.png",
"_turntable/up_6ed4293c/views/view_018_270deg.png",
"_turntable/up_6ed4293c/views/view_017_255deg.png",
"_turntable/up_6ed4293c/views/view_016_240deg.png",
"_turntable/up_6ed4293c/views/view_015_225deg.png",
"_turntable/up_6ed4293c/views/view_014_210deg.png",
"_turntable/up_6ed4293c/views/view_013_195deg.png",
"_turntable/up_6ed4293c/views/view_012_180deg.png",
"_turntable/up_6ed4293c/views/view_011_165deg.png",
"_turntable/up_6ed4293c/views/view_010_150deg.png",
"_turntable/up_6ed4293c/views/view_009_135deg.png",
"_turntable/up_6ed4293c/views/view_008_120deg.png",
"_turntable/up_6ed4293c/views/view_007_105deg.png",
"_turntable/up_6ed4293c/views/view_006_090deg.png",
"_turntable/up_6ed4293c/views/view_005_075deg.png",
"_turntable/up_6ed4293c/views/view_004_060deg.png",
"_turntable/up_6ed4293c/views/view_003_045deg.png",
"_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_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_232526_pad_20260626_035101_pad_20260626_034830_jb.nobg.png",
"20260627_232302_pad_20260627_232133_image.png", "20260627_232302_pad_20260627_232133_image.png",
"20260627_232238_pad_20260627_232133_image.png", "20260627_232238_pad_20260627_232133_image.png",
@@ -2257,9 +2342,6 @@
"_turntable/cg_7c7b22c9/views/view_022_330deg.png", "_turntable/cg_7c7b22c9/views/view_022_330deg.png",
"20260618_015124_20260618_010617_20260615_153426_img_12.nobg.png", "20260618_015124_20260618_010617_20260615_153426_img_12.nobg.png",
"_turntable/cg_5991f3f2/views/view_002_030deg.png", "_turntable/cg_5991f3f2/views/view_002_030deg.png",
"_turntable/cg_5991f3f2/views/view_022_330deg.png",
"_turntable/up_6ed4293c/views/view_021_315deg.png",
"_turntable/up_6ed4293c/views/view_020_300deg.png",
"20260627_213910_best_02_person_0.874_2026-06-27_12-18-49.png", "20260627_213910_best_02_person_0.874_2026-06-27_12-18-49.png",
"20260627_213840_best_02_person_0.874_2026-06-27_12-18-49.png", "20260627_213840_best_02_person_0.874_2026-06-27_12-18-49.png",
"20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.png", "20260627_213816_best_01_person_0.874_2026-06-27_12-18-49.png",
@@ -2286,7 +2368,6 @@
"20260627_213202_9_20260627_212803_best_01_person_0.874_2026-06-27_20-05-05.jpg.png", "20260627_213202_9_20260627_212803_best_01_person_0.874_2026-06-27_20-05-05.jpg.png",
"20260627_213149_8_20260627_212803_best_01_person_0.874_2026-06-27_20-05-05.jpg.png", "20260627_213149_8_20260627_212803_best_01_person_0.874_2026-06-27_20-05-05.jpg.png",
"_turntable/cg_74544975/views/view_004_060deg.png", "_turntable/cg_74544975/views/view_004_060deg.png",
"_turntable/up_6ed4293c/views/view_019_285deg.png",
"_turntable/cg_74544975/views/view_001_015deg.png", "_turntable/cg_74544975/views/view_001_015deg.png",
"_turntable/cg_74544975/views/view_000_000deg.png", "_turntable/cg_74544975/views/view_000_000deg.png",
"20260627_213102_12_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png", "20260627_213102_12_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
@@ -2318,25 +2399,6 @@
"20260627_212723_1_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png", "20260627_212723_1_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
"20260627_212710_0_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png", "20260627_212710_0_20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
"20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png", "20260627_212658_best_00_person_0.874_2026-06-27_12-18-49.png",
"_turntable/up_6ed4293c/views/view_018_270deg.png",
"_turntable/up_6ed4293c/views/view_017_255deg.png",
"_turntable/up_6ed4293c/views/view_016_240deg.png",
"_turntable/up_6ed4293c/views/view_015_225deg.png",
"_turntable/up_6ed4293c/views/view_014_210deg.png",
"_turntable/up_6ed4293c/views/view_013_195deg.png",
"_turntable/up_6ed4293c/views/view_012_180deg.png",
"_turntable/up_6ed4293c/views/view_011_165deg.png",
"_turntable/up_6ed4293c/views/view_010_150deg.png",
"_turntable/up_6ed4293c/views/view_009_135deg.png",
"_turntable/up_6ed4293c/views/view_008_120deg.png",
"_turntable/up_6ed4293c/views/view_007_105deg.png",
"_turntable/up_6ed4293c/views/view_006_090deg.png",
"_turntable/up_6ed4293c/views/view_005_075deg.png",
"_turntable/up_6ed4293c/views/view_004_060deg.png",
"_turntable/up_6ed4293c/views/view_003_045deg.png",
"_turntable/up_6ed4293c/views/view_002_030deg.png",
"_turntable/up_6ed4293c/views/view_001_015deg.png",
"_turntable/up_6ed4293c/views/view_000_000deg.png",
"20260627_204815_image.png", "20260627_204815_image.png",
"20260627_204804_image.png", "20260627_204804_image.png",
"20260627_204756_image.png", "20260627_204756_image.png",
@@ -2360,10 +2422,8 @@
"_turntable/cg_75340e8e/views/view_001_015deg.png", "_turntable/cg_75340e8e/views/view_001_015deg.png",
"_turntable/cg_75340e8e/views/view_000_000deg.png", "_turntable/cg_75340e8e/views/view_000_000deg.png",
"20260627_203706_11_20260627_200330_image.png", "20260627_203706_11_20260627_200330_image.png",
"up_7b6eaabb_face.png",
"20260627_203654_11_20260627_200330_image.png", "20260627_203654_11_20260627_200330_image.png",
"20260627_203642_11_20260627_200330_image.png", "20260627_203642_11_20260627_200330_image.png",
"20260627_203626_image.png",
"20260627_203607_image.png", "20260627_203607_image.png",
"20260627_203549_image.png", "20260627_203549_image.png",
"20260627_203531_Screenshot_From_2026-06-27_20-04-11.png", "20260627_203531_Screenshot_From_2026-06-27_20-04-11.png",
@@ -2408,7 +2468,6 @@
"_turntable/up_c8be3aa7/views/view_004_060deg.png", "_turntable/up_c8be3aa7/views/view_004_060deg.png",
"_turntable/up_c8be3aa7/views/view_001_015deg.png", "_turntable/up_c8be3aa7/views/view_001_015deg.png",
"20260627_200711_11_20260627_200330_image.png", "20260627_200711_11_20260627_200330_image.png",
"20260627_200632_9_20260627_200330_image.png",
"20260627_200506_image.png", "20260627_200506_image.png",
"20260627_200422_2_20260627_200330_image.png", "20260627_200422_2_20260627_200330_image.png",
"20260627_200414_image.png", "20260627_200414_image.png",
@@ -2439,7 +2498,6 @@
"_turntable/img_87.png/views/view_017_255deg.png", "_turntable/img_87.png/views/view_017_255deg.png",
"_turntable/img_87.png/views/view_016_240deg.png", "_turntable/img_87.png/views/view_016_240deg.png",
"_turntable/img_87.png/views/view_015_225deg.png", "_turntable/img_87.png/views/view_015_225deg.png",
"_turntable/up_aa1add4d/views/view_023_345deg.png",
"_turntable/up_aa1add4d/views/view_022_330deg.png", "_turntable/up_aa1add4d/views/view_022_330deg.png",
"_turntable/up_aa1add4d/views/view_021_315deg.png", "_turntable/up_aa1add4d/views/view_021_315deg.png",
"_turntable/up_aa1add4d/views/view_020_300deg.png", "_turntable/up_aa1add4d/views/view_020_300deg.png",
@@ -2459,9 +2517,7 @@
"_turntable/up_aa1add4d/views/view_006_090deg.png", "_turntable/up_aa1add4d/views/view_006_090deg.png",
"_turntable/up_aa1add4d/views/view_005_075deg.png", "_turntable/up_aa1add4d/views/view_005_075deg.png",
"_turntable/up_aa1add4d/views/view_004_060deg.png", "_turntable/up_aa1add4d/views/view_004_060deg.png",
"_turntable/up_aa1add4d/views/view_003_045deg.png",
"_turntable/up_aa1add4d/views/view_002_030deg.png", "_turntable/up_aa1add4d/views/view_002_030deg.png",
"_turntable/up_aa1add4d/views/view_001_015deg.png",
"_turntable/up_aa1add4d/views/view_000_000deg.png", "_turntable/up_aa1add4d/views/view_000_000deg.png",
"_turntable/up_ae825fce/views/view_023_345deg.png", "_turntable/up_ae825fce/views/view_023_345deg.png",
"_turntable/up_ae825fce/views/view_022_330deg.png", "_turntable/up_ae825fce/views/view_022_330deg.png",
@@ -4327,7 +4383,6 @@
"_turntable/cg_f0049873/views/view_002_030deg.png", "_turntable/cg_f0049873/views/view_002_030deg.png",
"_turntable/cg_f0049873/views/view_001_015deg.png", "_turntable/cg_f0049873/views/view_001_015deg.png",
"_turntable/cg_f0049873/views/view_000_000deg.png", "_turntable/cg_f0049873/views/view_000_000deg.png",
"_turntable/cg_08203e88/turntable.mp4",
"_turntable/cg_08203e88/views/view_023_345deg.png", "_turntable/cg_08203e88/views/view_023_345deg.png",
"_turntable/cg_08203e88/views/view_022_330deg.png", "_turntable/cg_08203e88/views/view_022_330deg.png",
"_turntable/cg_08203e88/views/view_021_315deg.png", "_turntable/cg_08203e88/views/view_021_315deg.png",
@@ -4625,7 +4680,6 @@
"20260626_021942_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_021533_pad_20260625_205828_4_20260625_205722_image.png",
"20260626_014422_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png", "20260626_014422_pad_20260626_005644_dup_20260626_005504_Pasted image (9).nobg.png",
"cg_809653a0_face.png",
"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).nobg.png",
"20260626_005504_Pasted image (9).png", "20260626_005504_Pasted image (9).png",
@@ -4994,7 +5048,6 @@
"20260624_154453_img_72.png", "20260624_154453_img_72.png",
"20260624_154442_img_72.png", "20260624_154442_img_72.png",
"20260624_154318_img_72.png", "20260624_154318_img_72.png",
"cg_b7e71d34_face.png",
"20260624_154236_img_72.png", "20260624_154236_img_72.png",
"20260624_154125_img_72.png", "20260624_154125_img_72.png",
"20260624_153940_img_72.png", "20260624_153940_img_72.png",
@@ -5002,7 +5055,6 @@
"20260624_153614_Screenshot_From_2026-06-17_07-41-27.png", "20260624_153614_Screenshot_From_2026-06-17_07-41-27.png",
"20260624_153417_Screenshot_From_2026-06-17_07-41-27.png", "20260624_153417_Screenshot_From_2026-06-17_07-41-27.png",
"20260624_153344_Screenshot_From_2026-06-17_07-41-27.png", "20260624_153344_Screenshot_From_2026-06-17_07-41-27.png",
"20260624_153311_Screenshot_From_2026-06-17_07-41-27.png",
"20260624_153102_Screenshot_From_2026-06-17_07-41-27.png", "20260624_153102_Screenshot_From_2026-06-17_07-41-27.png",
"20260624_153051_Screenshot_From_2026-06-17_07-41-27.png", "20260624_153051_Screenshot_From_2026-06-17_07-41-27.png",
"20260624_153040_Screenshot_From_2026-06-17_07-41-27.png", "20260624_153040_Screenshot_From_2026-06-17_07-41-27.png",
@@ -6615,7 +6667,6 @@
"20260616_004250_img_18.png", "20260616_004250_img_18.png",
"20260616_003916_img_15.png", "20260616_003916_img_15.png",
"20260616_003803_img_14.png", "20260616_003803_img_14.png",
"20260615_153426_img_12.png",
"20260615_152252_imgxxx.png", "20260615_152252_imgxxx.png",
"20260615_151829_img_92.png", "20260615_151829_img_92.png",
"20260615_151614_img_93.png", "20260615_151614_img_93.png",
@@ -6724,6 +6775,7 @@
let _sbPadBottom = 0; let _sbPadBottom = 0;
let _sbPadLeft = 0; let _sbPadLeft = 0;
let _sbPadOutpaint = false; let _sbPadOutpaint = false;
let _sbPadUniform = false;
const CAMERA_ANGLES = [ const CAMERA_ANGLES = [
// Absolute camera positions // Absolute camera positions
@@ -6882,43 +6934,218 @@
} }
function formatPadValue(v) { function formatPadValue(v) {
if (v === undefined || v === null || v === '') return '0.00'; if (v === undefined || v === null || v === '') return '0.00%';
let val = parseFloat(v); const s = v.toString().trim().toLowerCase();
if (isNaN(val)) return '0.00'; let val = parseFloat(s);
const s = v.toString(); if (isNaN(val)) return '0.00%';
// User requested: show 0.00 if it contains a decimal see it as percentage.
// if integer and its greater than 10 its pixel value // If the user explicitly typed px
if (s.includes('.') || s.includes('%')) return val.toFixed(2); if (s.endsWith('px')) {
if (val > 10) return val.toString(); return val.toString(); // integer pixels
return val.toFixed(2); }
// If the user explicitly typed %
if (s.endsWith('%')) {
return val.toFixed(2) + '%';
}
// If contains a decimal, or if it's <= 10 (or < 1.0), it's percentage
if (s.includes('.') || val <= 10) {
// If it's a fraction like 0.1, convert to 10%
if (val > 0 && val < 1.0) {
return (val * 100).toFixed(2) + '%';
}
return val.toFixed(2) + '%';
}
// Otherwise, it's integer > 10, treat as pixels
return val.toString();
} }
function syncPadFields(el) { function syncPadFields(el) {
const id = el.id; // padLeft or sbPadLeft const id = el.id; // padLeft, padTop, etc. or sbPadLeft, sbPadTop
let pairId = null; const val = el.value;
if (id.endsWith('Left')) pairId = id.replace('Left', 'Right'); const prefix = id.startsWith('sbPad') ? 'sbPad' : 'pad';
else if (id.endsWith('Right')) pairId = id.replace('Right', 'Left'); const isSb = id.startsWith('sbPad');
else if (id.endsWith('Top')) pairId = id.replace('Top', 'Bottom');
else if (id.endsWith('Bottom')) pairId = id.replace('Bottom', 'Top');
if (pairId) { // Check if Uniform mode is active for this context
const pairEl = document.getElementById(pairId); const uniformEl = document.getElementById(isSb ? 'sbPadUniform' : 'padUniform');
// only sync if the other side is 'empty' (0) const isUniform = uniformEl ? uniformEl.checked : false;
if (pairEl && (pairEl.value === '0.00' || pairEl.value === '0' || pairEl.value === '')) {
pairEl.value = el.value; if (isUniform) {
// Set all four sides to the same value
['Top', 'Right', 'Bottom', 'Left'].forEach(side => {
const targetId = prefix + side;
const targetEl = document.getElementById(targetId);
if (targetEl && targetEl !== el) {
targetEl.value = val;
}
});
} else {
// Standard opposite-side sync if other side is 0
let pairId = null;
if (id.endsWith('Left')) pairId = id.replace('Left', 'Right');
else if (id.endsWith('Right')) pairId = id.replace('Right', 'Left');
else if (id.endsWith('Top')) pairId = id.replace('Top', 'Bottom');
else if (id.endsWith('Bottom')) pairId = id.replace('Bottom', 'Top');
if (pairId) {
const pairEl = document.getElementById(pairId);
if (pairEl && (pairEl.value === '0.00' || pairEl.value === '0' || pairEl.value === '0.00%' || pairEl.value === '')) {
pairEl.value = val;
}
} }
} }
if (id.startsWith('sbPad')) { if (isSb) {
updateSbGenBtn(); updateSbGenBtn();
} }
updatePadPreview(); updatePadPreview();
} }
function togglePadUniform(cb) {
const isSb = cb.id.startsWith('sbPad');
const prefix = isSb ? 'sbPad' : 'pad';
if (isSb) _sbPadUniform = cb.checked;
if (cb.checked) {
// Find a non-zero value, or default to Top, and propagate
const sides = ['Top', 'Right', 'Bottom', 'Left'];
let val = '0.00%';
for (let side of sides) {
const el = document.getElementById(prefix + side);
if (el && el.value && el.value !== '0' && el.value !== '0.00' && el.value !== '0.00%') {
val = el.value;
break;
}
}
sides.forEach(side => {
const el = document.getElementById(prefix + side);
if (el) el.value = val;
});
updatePadPreview();
}
}
function applyPadPreset(presetVal, isSb) {
const prefix = isSb ? 'sbPad' : 'pad';
const sides = ['Top', 'Right', 'Bottom', 'Left'];
if (presetVal === '0') {
// Reset all
sides.forEach(side => {
const el = document.getElementById(prefix + side);
if (el) el.value = '0.00%';
});
const uniformEl = document.getElementById(isSb ? 'sbPadUniform' : 'padUniform');
if (uniformEl) uniformEl.checked = false;
if (isSb) _sbPadUniform = false;
} else {
// If it ends with %
let formatted = presetVal;
if (presetVal.endsWith('%')) {
formatted = parseFloat(presetVal).toFixed(2) + '%';
} else if (presetVal.endsWith('px')) {
formatted = parseFloat(presetVal).toString();
}
sides.forEach(side => {
const el = document.getElementById(prefix + side);
if (el) el.value = formatted;
});
const uniformEl = document.getElementById(isSb ? 'sbPadUniform' : 'padUniform');
if (uniformEl) uniformEl.checked = true;
if (isSb) _sbPadUniform = true;
}
if (isSb) {
updateSbGenBtn();
}
updatePadPreview();
}
function getImageDisplayRect(img) {
if (!img || !img.naturalWidth) return null;
// If the image is zoomed, getBoundingClientRect() returns the SCALED dimensions.
// We need to account for this to find the actual visible image area.
const isZoomed = img.classList.contains('zoomed');
const scale = isZoomed ? 2.2 : 1.0;
const rect = img.getBoundingClientRect();
const container = img.parentElement;
const crect = container.getBoundingClientRect();
// The natural aspect ratio
const iw = img.naturalWidth;
const ih = img.naturalHeight;
const aspect = iw / ih;
// The container dimensions for the image (before scaling)
const cw = rect.width / scale;
const ch = rect.height / scale;
const caspect = cw / ch;
let dw, dh, dl, dt;
if (aspect > caspect) {
dw = cw;
dh = cw / aspect;
dl = 0;
dt = (ch - dh) / 2;
} else {
dh = ch;
dw = ch * aspect;
dt = 0;
dl = (cw - dw) / 2;
}
// Adjust for scale: the visual center stays the same,
// but the width/height and offsets from the top-left change.
if (isZoomed) {
const centerRelX = cw / 2;
const centerRelY = ch / 2;
// dw, dh are unscaled visual dimensions of the content.
// dl, dt are unscaled offsets within the img element's box.
// We want the scaled versions.
dw *= scale;
dh *= scale;
// The top-left of the content relative to the center of the img element:
const contentRelCenterX = dl - centerRelX;
const contentRelCenterY = dt - centerRelY;
// The top-left of the content relative to the center of the img element (scaled):
const contentRelCenterXScaled = contentRelCenterX * scale;
const contentRelCenterYScaled = contentRelCenterY * scale;
// The new offsets relative to the top-left of the scaled img element:
// Actually, rect.left/top are already for the scaled element's bounding box.
// We just need to find where the content starts within that bounding box.
const finalDl = contentRelCenterXScaled + (rect.width / 2);
const finalDt = contentRelCenterYScaled + (rect.height / 2);
return {
left: rect.left - crect.left + finalDl,
top: rect.top - crect.top + finalDt,
width: dw,
height: dh
};
}
const finalLeft = rect.left - crect.left + dl;
const finalTop = rect.top - crect.top + dt;
return { left: finalLeft, top: finalTop, width: dw, height: dh };
}
function updatePadPreview() { function updatePadPreview() {
const viewer = document.getElementById('studioViewer'); const viewer = document.getElementById('studioViewer');
if (!viewer) return; if (viewer) {
// Clean up existing preview overlays // Clean up existing preview overlays
viewer.querySelectorAll('.pad-preview-overlay').forEach(el => el.remove()); viewer.querySelectorAll('.pad-preview-overlay').forEach(el => el.remove());
}
const img = document.getElementById('lbImg');
if (!viewer || !img || img.style.display === 'none') return;
const rect = getImageDisplayRect(img);
if (!rect) return;
const isManual = !!document.getElementById('padBar'); const isManual = !!document.getElementById('padBar');
const getV = (side) => { const getV = (side) => {
@@ -6926,27 +7153,52 @@
return el ? el.value : '0'; return el ? el.value : '0';
}; };
const sides = ['Left', 'Right']; const fill = document.getElementById('padFill')?.value || 'transparent';
const sides = ['Left', 'Right', 'Top', 'Bottom'];
sides.forEach(side => { sides.forEach(side => {
const vStr = getV(side); const vStr = getV(side);
if (!vStr || vStr === '0' || vStr === '0.00') return; if (!vStr || vStr === '0' || vStr === '0.00' || vStr === '0.00%') return;
const val = parseFloat(vStr); const val = parseFloat(vStr);
if (isNaN(val) || val <= 0) return; if (isNaN(val) || val <= 0) return;
const isPercent = vStr.includes('%') || vStr.includes('.'); const isPercent = vStr.includes('%');
const width = isPercent ? (val + '%') : (val + 'px');
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'pad-preview-overlay'; overlay.className = 'pad-preview-overlay';
overlay.style.top = '0';
overlay.style.bottom = '0'; // Style based on selected fill mode to make preview professional & intuitive
if (side === 'Left') { if (isManual) {
overlay.style.left = '0'; if (fill === 'black') {
overlay.style.width = width; overlay.style.background = 'rgba(0, 0, 0, 0.7)';
overlay.style.borderColor = '#444';
} else if (fill === 'white') {
overlay.style.background = 'rgba(255, 255, 255, 0.7)';
overlay.style.borderColor = '#ccc';
} else {
overlay.style.background = 'rgba(245, 158, 11, 0.4)';
overlay.style.borderColor = '#f59e0b';
}
} else { } else {
overlay.style.right = '0'; // Sidebar is always transparent background before outpaint
overlay.style.width = width; overlay.style.background = 'rgba(245, 158, 11, 0.4)';
overlay.style.borderColor = '#f59e0b';
}
if (side === 'Left' || side === 'Right') {
const w = isPercent ? (rect.width * val / 100) : val;
overlay.style.width = w + 'px';
overlay.style.top = rect.top + 'px';
overlay.style.height = rect.height + 'px';
if (side === 'Left') overlay.style.left = (rect.left - w) + 'px';
else overlay.style.left = (rect.left + rect.width) + 'px';
} else {
const h = isPercent ? (rect.height * val / 100) : val;
overlay.style.height = h + 'px';
overlay.style.left = rect.left + 'px';
overlay.style.width = rect.width + 'px';
if (side === 'Top') overlay.style.top = (rect.top - h) + 'px';
else overlay.style.top = (rect.top + rect.height) + 'px';
} }
viewer.appendChild(overlay); viewer.appendChild(overlay);
}); });
@@ -7071,21 +7323,54 @@
const fname = lbNames[lbIdx]; const fname = lbNames[lbIdx];
if (document.getElementById('poseCanvas')) _removePoseOverlay(); // skeleton is per-image if (document.getElementById('poseCanvas')) _removePoseOverlay(); // skeleton is per-image
document.getElementById('poseResults')?.remove(); document.getElementById('poseResults')?.remove();
_fsModelFilename = fname; // keep faceswap/scenery/segment in sync
const isVid = isVideo(fname) || fileContentType[fname] === 'video';
const lbImgEl = document.getElementById('lbImg'); const lbImgEl = document.getElementById('lbImg');
const lbVideoEl = document.getElementById('lbVideo'); const lbVideoEl = document.getElementById('lbVideo');
lbImgEl.onload = () => {
const dimEl = document.getElementById('lbImgDims');
if (dimEl) dimEl.textContent = `${lbImgEl.naturalWidth} x ${lbImgEl.naturalHeight}`;
// Force layout recalculation and overlay sync when a new image loads
requestAnimationFrame(() => {
updatePadPreview();
const cb = document.getElementById('sbCheckerboard');
if (cb && cb.checked) toggleCheckerboard(true);
});
};
_fsModelFilename = fname; // keep faceswap/scenery/segment in sync
const isVid = isVideo(fname) || fileContentType[fname] === 'video';
// Show image dimensions
const dimEl = document.getElementById('lbImgDims');
if (dimEl) {
if (!isVid && lbImgEl.naturalWidth) {
dimEl.textContent = `${lbImgEl.naturalWidth} x ${lbImgEl.naturalHeight}`;
} else {
dimEl.textContent = '';
}
}
if (isVid) { if (isVid) {
lbImgEl.style.display = 'none'; lbImgEl.style.display = 'none';
lbVideoEl.style.display = ''; lbVideoEl.style.display = '';
lbVideoEl.poster = posterFor(lbUrls[lbIdx]); lbVideoEl.poster = posterFor(lbUrls[lbIdx]);
lbVideoEl.src = lbUrls[lbIdx]; lbVideoEl.src = lbUrls[lbIdx];
// Explicitly clean up pad previews and hide checkerboard when navigating to a video
updatePadPreview();
toggleCheckerboard(false);
} else { } else {
lbVideoEl.style.display = 'none'; lbVideoEl.style.display = 'none';
lbVideoEl.src = ''; lbVideoEl.src = '';
lbImgEl.style.display = ''; lbImgEl.style.display = '';
lbImgEl.classList.remove('zoomed'); lbImgEl.classList.remove('zoomed');
lbImgEl.src = lbUrls[lbIdx]; lbImgEl.src = lbUrls[lbIdx];
// If it's already in cache, onload might not fire or already fired
if (lbImgEl.complete && lbImgEl.naturalWidth) {
lbImgEl.onload();
}
} }
const backBtn = document.getElementById('lbBackBtn'); const backBtn = document.getElementById('lbBackBtn');
@@ -7863,7 +8148,7 @@
const timeLabel = document.getElementById('pvmTimeLabel'); const timeLabel = document.getElementById('pvmTimeLabel');
function pvmSetVideo(filename) { function pvmSetVideo(filename) {
vidEl.src = `${API}/output/${encodeURIComponent(filename)}`; vidEl.src = IMAGE_FOLDER + filename;
vidEl.load(); vidEl.load();
vidEl.addEventListener('loadedmetadata', () => { vidEl.addEventListener('loadedmetadata', () => {
const dur = vidEl.duration || 10; const dur = vidEl.duration || 10;
@@ -8198,15 +8483,24 @@
const numInput = (id, label) => { const numInput = (id, label) => {
const onInput = `syncPadFields(this)`; const onInput = `syncPadFields(this)`;
const onBlur = `this.value = formatPadValue(this.value); updatePadPreview()`; const onBlur = `this.value = formatPadValue(this.value); updatePadPreview()`;
return `<label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#ccc">${label}<input type="text" id="${id}" value="0.00" oninput="${onInput}" onblur="${onBlur}" style="width:52px;background:#111;border:1px solid #333;color:#ccc;border-radius:4px;padding:2px 4px;font-size:11px;text-align:right" placeholder="px or %"></label>`; const onKeyDown = `if(event.key === 'Enter') { confirmManualPad(); event.preventDefault(); } if(event.key === 'Escape') { cancelManualPad(); event.preventDefault(); }`;
return `<label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#ccc">${label}<input type="text" id="${id}" value="0.00%" oninput="${onInput}" onblur="${onBlur}" onkeydown="${onKeyDown}" style="width:52px;background:#111;border:1px solid #333;color:#ccc;border-radius:4px;padding:2px 4px;font-size:11px;text-align:right" placeholder="px or %"></label>`;
}; };
bar.innerHTML = bar.innerHTML =
`<span style="flex:1;font-size:11px;color:#aaa">Pad canvas (px or %):</span>` `<span style="font-size:11px;color:#aaa">Preset:</span>`
+ `<button class="sb-btn" style="padding:2px 6px;font-size:10px;margin-right:2px;" onclick="applyPadPreset('10%', false)">+10%</button>`
+ `<button class="sb-btn" style="padding:2px 6px;font-size:10px;margin-right:2px;" onclick="applyPadPreset('20%', false)">+20%</button>`
+ `<button class="sb-btn" style="padding:2px 6px;font-size:10px;margin-right:2px;" onclick="applyPadPreset('50%', false)">+50%</button>`
+ `<button class="sb-btn" style="padding:2px 6px;font-size:10px;margin-right:4px;" onclick="applyPadPreset('100px', false)">+100px</button>`
+ `<button class="sb-btn" style="padding:2px 6px;font-size:10px;margin-right:8px;background:#3b0764;color:#d8b4fe;border-color:#581c87" onclick="applyPadPreset('0', false)">Reset</button>`
+ `<span style="font-size:11px;color:#aaa;margin-right:4px;">Custom:</span>`
+ numInput('padTop','↑') + numInput('padTop','↑')
+ numInput('padRight','→') + numInput('padRight','→')
+ numInput('padBottom','↓') + numInput('padBottom','↓')
+ numInput('padLeft','←') + numInput('padLeft','←')
+ `<select id="padFill" style="background:#111;border:1px solid #333;color:#aaa;border-radius:4px;font-size:11px;padding:2px 5px"> + `<label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#ccc;cursor:pointer" title="Keep all padding values equal">`
+ `<input type="checkbox" id="padUniform" style="cursor:pointer" onchange="togglePadUniform(this)">Uniform</label>`
+ `<select id="padFill" onchange="updatePadPreview()" style="background:#111;border:1px solid #333;color:#aaa;border-radius:4px;font-size:11px;padding:2px 5px">
<option value="transparent">Transparent</option> <option value="transparent">Transparent</option>
<option value="black">Black</option> <option value="black">Black</option>
<option value="white">White</option> <option value="white">White</option>
@@ -8216,7 +8510,7 @@
+ `<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#ccc;cursor:pointer" title="Instruct the model to fill in the padded area">` + `<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#ccc;cursor:pointer" title="Instruct the model to fill in the padded area">`
+ `<input type="checkbox" id="padOutpaint" style="cursor:pointer">Outpaint</label>` + `<input type="checkbox" id="padOutpaint" style="cursor:pointer">Outpaint</label>`
+ `<button class="sb-btn" onclick="cancelManualPad()">Cancel</button>` + `<button class="sb-btn" onclick="cancelManualPad()">Cancel</button>`
+ `<button class="sb-btn primary" id="padConfirmBtn" onclick="confirmManualPad()">Pad</button>`; + `<button class="sb-btn primary" id="padConfirmBtn" onclick="confirmManualPad()">Expand</button>`;
viewer.appendChild(bar); viewer.appendChild(bar);
updatePadPreview(); updatePadPreview();
} }
@@ -8279,12 +8573,11 @@
refreshNow(true); refreshNow(true);
} else { } else {
showToast('Padded', 'success'); showToast('Padded', 'success');
lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now(); _bustStudioImage(fname);
updateStudio();
} }
} else { } else {
showToast('Pad failed: ' + await r.text(), 'error'); showToast('Pad failed: ' + await r.text(), 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Pad'; } if (btn) { btn.disabled = false; btn.textContent = 'Expand'; }
} }
} catch (e) { } catch (e) {
showToast('Pad error: ' + e, 'error'); showToast('Pad error: ' + e, 'error');
@@ -8600,7 +8893,7 @@
} else if (jd.video_filename) { } else if (jd.video_filename) {
// Depth-card MP4 fallback // Depth-card MP4 fallback
const mp4 = jd.video_filename; const mp4 = jd.video_filename;
const src = mp4.includes('/') ? `${API}/output/${mp4}` : IMAGE_FOLDER + mp4; const src = IMAGE_FOLDER + mp4;
const vid = document.getElementById('orbitVideo'); const vid = document.getElementById('orbitVideo');
vid.src = src + '?t=' + Date.now(); vid.src = src + '?t=' + Date.now();
vid.style.display = ''; vid.style.display = '';
@@ -8618,7 +8911,7 @@
// ---- Frame flipper engine ---- // ---- Frame flipper engine ----
function _orbitStartFlipper(frameRelPaths) { function _orbitStartFlipper(frameRelPaths) {
_orbitFlipperStop(); _orbitFlipperStop();
_orbitFrames = frameRelPaths.map(p => `${API}/output/${p}`); _orbitFrames = frameRelPaths.map(p => IMAGE_FOLDER + p);
_orbitFlipperIdx = 0; _orbitFlipperIdx = 0;
_orbitFlipperRunning = true; _orbitFlipperRunning = true;
@@ -8648,7 +8941,7 @@
_orbitFlipperStop(); _orbitFlipperStop();
document.getElementById('orbitFlipperPlayBtn').textContent = '▶ Play'; document.getElementById('orbitFlipperPlayBtn').textContent = '▶ Play';
} else { } else {
_orbitStartFlipper(_orbitFrames.map(u => u.replace(`${API}/output/`, ''))); _orbitStartFlipper(_orbitFrames.map(u => u.startsWith(IMAGE_FOLDER) ? u.slice(IMAGE_FOLDER.length) : u));
document.getElementById('orbitFlipperPlayBtn').textContent = '⏸ Pause'; document.getElementById('orbitFlipperPlayBtn').textContent = '⏸ Pause';
} }
} }
@@ -8658,7 +8951,7 @@
if (_orbitFlipperRunning && _orbitFrames.length > 0) { if (_orbitFlipperRunning && _orbitFrames.length > 0) {
// Restart with new interval // Restart with new interval
_orbitFlipperStop(); _orbitFlipperStop();
_orbitStartFlipper(_orbitFrames.map(u => u.replace(`${API}/output/`, ''))); _orbitStartFlipper(_orbitFrames.map(u => u.startsWith(IMAGE_FOLDER) ? u.slice(IMAGE_FOLDER.length) : u));
} }
} }
@@ -8666,9 +8959,7 @@
function orbitDownload() { function orbitDownload() {
if (!_orbitCurrentMp4) return; if (!_orbitCurrentMp4) return;
const a = document.createElement('a'); const a = document.createElement('a');
a.href = _orbitCurrentMp4.includes('/') a.href = IMAGE_FOLDER + _orbitCurrentMp4;
? `${API}/output/${_orbitCurrentMp4}`
: IMAGE_FOLDER + _orbitCurrentMp4;
a.download = _orbitCurrentMp4.split('/').pop(); a.download = _orbitCurrentMp4.split('/').pop();
a.click(); a.click();
} }
@@ -8787,7 +9078,7 @@
for (const t of _turntablesData) { for (const t of _turntablesData) {
const label = t.group_name || t.group_id.slice(0, 8); const label = t.group_name || t.group_id.slice(0, 8);
const thumb = t.frames.length > 0 ? `${API}/output/${t.frames[0]}` : ''; const thumb = t.frames.length > 0 ? IMAGE_FOLDER + t.frames[0] : '';
const compClass = t.completed ? ' complete' : ''; const compClass = t.completed ? ' complete' : '';
html += `<div class="orbit-tab-card${compClass}" onclick="_orbitTabOpen('${t.group_id}')" id="otc_${t.group_id}"> html += `<div class="orbit-tab-card${compClass}" onclick="_orbitTabOpen('${t.group_id}')" id="otc_${t.group_id}">
<div class="otc-img-wrap"><img id="otcImg_${t.group_id}" src="${thumb}" alt="${label}"></div> <div class="otc-img-wrap"><img id="otcImg_${t.group_id}" src="${thumb}" alt="${label}"></div>
@@ -8809,7 +9100,7 @@
function _orbitStartTabFlipper(groupId, frameRels) { function _orbitStartTabFlipper(groupId, frameRels) {
const imgEl = document.getElementById(`otcImg_${groupId}`); const imgEl = document.getElementById(`otcImg_${groupId}`);
if (!imgEl) return; if (!imgEl) return;
const urls = frameRels.map(f => `${API}/output/${f}`); const urls = frameRels.map(f => IMAGE_FOLDER + f);
let idx = 0; let idx = 0;
if (_orbitTabFlippers[groupId]) clearInterval(_orbitTabFlippers[groupId].timer); if (_orbitTabFlippers[groupId]) clearInterval(_orbitTabFlippers[groupId].timer);
const timer = setInterval(() => { const timer = setInterval(() => {
@@ -8830,7 +9121,7 @@
// Stop any existing full-player // Stop any existing full-player
if (viewer._flipperTimer) { clearTimeout(viewer._flipperTimer); viewer._flipperTimer = null; } if (viewer._flipperTimer) { clearTimeout(viewer._flipperTimer); viewer._flipperTimer = null; }
const urls = t.frames.map(f => `${API}/output/${f}`); const urls = t.frames.map(f => IMAGE_FOLDER + f);
let idx = 0; let idx = 0;
const label = t.group_name || t.group_id.slice(0, 8); const label = t.group_name || t.group_id.slice(0, 8);
@@ -9802,6 +10093,29 @@
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const img = document.getElementById('lbImg');
if (img) {
const ro = new ResizeObserver(() => {
if (_isStudioOpen()) {
requestAnimationFrame(() => {
updatePadPreview();
const cb = document.getElementById('sbCheckerboard');
if (cb && cb.checked) toggleCheckerboard(true);
});
}
});
ro.observe(img);
}
window.addEventListener('resize', () => {
if (_isStudioOpen()) {
requestAnimationFrame(() => {
updatePadPreview();
const cb = document.getElementById('sbCheckerboard');
if (cb && cb.checked) toggleCheckerboard(true);
});
}
});
loadPromptHistory(); loadPromptHistory();
loadImages(); loadImages();
autoRefreshTimer = setInterval(_timedRefresh, REFRESH_INTERVAL * 1000); autoRefreshTimer = setInterval(_timedRefresh, REFRESH_INTERVAL * 1000);
@@ -10049,7 +10363,11 @@
} }
function toggleImageZoom() { function toggleImageZoom() {
document.getElementById('lbImg')?.classList.toggle('zoomed'); const img = document.getElementById('lbImg');
if (img) {
img.classList.toggle('zoomed');
// The ResizeObserver will handle the follow-up logic automatically.
}
} }
// ---- sidebar panels ---- // ---- sidebar panels ----
@@ -10064,6 +10382,7 @@
_sbPadBottom = document.getElementById('sbPadBottom')?.value ?? _sbPadBottom; _sbPadBottom = document.getElementById('sbPadBottom')?.value ?? _sbPadBottom;
_sbPadLeft = document.getElementById('sbPadLeft')?.value ?? _sbPadLeft; _sbPadLeft = document.getElementById('sbPadLeft')?.value ?? _sbPadLeft;
_sbPadOutpaint = document.getElementById('sbPadOutpaint')?.checked ?? _sbPadOutpaint; _sbPadOutpaint = document.getElementById('sbPadOutpaint')?.checked ?? _sbPadOutpaint;
_sbPadUniform = document.getElementById('sbPadUniform')?.checked ?? _sbPadUniform;
_fsSelectedPoses.forEach(name => { _fsSelectedPoses.forEach(name => {
const el = document.getElementById('sbPoseEdit_' + CSS.escape(name)); const el = document.getElementById('sbPoseEdit_' + CSS.escape(name));
if (el) _sbPoseEdits[name] = el.value; if (el) _sbPoseEdits[name] = el.value;
@@ -10233,8 +10552,15 @@
return `<label style="display:flex;align-items:center;gap:2px;font-size:11px;color:#888">${side[0]} return `<label style="display:flex;align-items:center;gap:2px;font-size:11px;color:#888">${side[0]}
<input type="text" id="sbPad${side}" value="${val}" placeholder="px or %" <input type="text" id="sbPad${side}" value="${val}" placeholder="px or %"
style="width:48px;background:#111;border:1px solid #2a2a2a;color:#ccc;border-radius:4px;padding:2px 4px;font-size:11px;text-align:right" style="width:48px;background:#111;border:1px solid #2a2a2a;color:#ccc;border-radius:4px;padding:2px 4px;font-size:11px;text-align:right"
oninput="${onInput}" onblur="${onBlur}"></label>`; oninput="${onInput}" onblur="${onBlur}"
onkeydown="if(event.key === 'Enter') { submitSbGenerate(); event.preventDefault(); }"></label>`;
}).join('')} }).join('')}
<label style="display:flex;align-items:center;gap:2px;font-size:11px;color:#888;cursor:pointer" title="Keep all padding values equal">
<input type="checkbox" id="sbPadUniform" ${_sbPadUniform?'checked':''} onchange="togglePadUniform(this)" style="cursor:pointer">Uniform</label>
<button class="sb-btn" style="padding:2px 5px;font-size:9px;margin:0" onclick="applyPadPreset('10%', true)">+10%</button>
<button class="sb-btn" style="padding:2px 5px;font-size:9px;margin:0" onclick="applyPadPreset('20%', true)">+20%</button>
<button class="sb-btn" style="padding:2px 5px;font-size:9px;margin:0" onclick="applyPadPreset('50%', true)">+50%</button>
<button class="sb-btn" style="padding:2px 5px;font-size:9px;margin:0;background:#3b0764;color:#d8b4fe;border-color:#581c87" onclick="applyPadPreset('0', true)">Reset</button>
<label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#888;cursor:pointer" title="Instruct the model to fill in the padded area"> <label style="display:flex;align-items:center;gap:3px;font-size:11px;color:#888;cursor:pointer" title="Instruct the model to fill in the padded area">
<input type="checkbox" id="sbPadOutpaint" ${(_sbPadOutpaint??false)?'checked':''} onchange="updateSbGenBtn()" style="cursor:pointer">Outpaint</label> <input type="checkbox" id="sbPadOutpaint" ${(_sbPadOutpaint??false)?'checked':''} onchange="updateSbGenBtn()" style="cursor:pointer">Outpaint</label>
</div> </div>
@@ -11646,9 +11972,30 @@
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
} }
function toggleCheckerboard(on) { function toggleCheckerboard(on, isRetry=false) {
const viewer = document.getElementById('studioViewer'); const viewer = document.getElementById('studioViewer');
const cbLayer = document.getElementById('studioCheckerboard');
const img = document.getElementById('lbImg');
if (viewer) viewer.classList.toggle('show-checker', on); if (viewer) viewer.classList.toggle('show-checker', on);
if (cbLayer) {
if (on && img && img.style.display !== 'none') {
const rect = getImageDisplayRect(img);
if (rect) {
cbLayer.style.display = 'block';
cbLayer.style.left = rect.left + 'px';
cbLayer.style.top = rect.top + 'px';
cbLayer.style.width = rect.width + 'px';
cbLayer.style.height = rect.height + 'px';
} else {
cbLayer.style.display = 'none';
}
} else {
cbLayer.style.display = 'none';
}
}
const cb = document.getElementById('sbCheckerboard'); const cb = document.getElementById('sbCheckerboard');
if (cb) cb.checked = on; if (cb) cb.checked = on;
} }

View File

@@ -307,6 +307,8 @@ def _idle_turntable_daemon():
fname, group_id, sort_order = row[0], row[2], row[6] fname, group_id, sort_order = row[0], row[2], row[6]
if not group_id: if not group_id:
continue continue
if fname.startswith("_turntable/"):
continue
if group_id not in groups: if group_id not in groups:
groups[group_id] = (fname, sort_order) groups[group_id] = (fname, sort_order)
else: else:
@@ -1378,24 +1380,73 @@ def _write_turntable_static() -> None:
except Exception: except Exception:
group_names = {} group_names = {}
# Preferred filename per group (sort_order=0) # Fetch active and existing group IDs and filenames from the database (excluding turntable assets)
preferred: dict = {} all_db_gids = set()
active_db_gids = set()
all_db_filenames = set()
active_db_filenames = set()
try: try:
for row in database.list_persons(): conn = database.get_db_connection()
fname, gid, sort_order = row[0], row[2], row[6] cur = conn.cursor()
if not gid: cur.execute("""
continue SELECT filename, group_id, archived, hidden
if gid not in preferred: FROM person
preferred[gid] = (fname, sort_order) WHERE NOT (filename LIKE '_turntable/%')
else: """)
cur = preferred[gid][1] for fname, gid, archived, hidden in cur.fetchall():
if sort_order is not None and (cur is None or sort_order < cur): is_archived = bool(archived) if archived is not None else False
preferred[gid] = (fname, sort_order) is_hidden = bool(hidden) if hidden is not None else False
except Exception:
pass all_db_filenames.add(fname)
if not is_archived and not is_hidden:
active_db_filenames.add(fname)
if gid:
all_db_gids.add(gid)
if not is_archived and not is_hidden:
active_db_gids.add(gid)
cur.close()
database._put_db_connection(conn)
except Exception as db_err:
print(f"[static] DB check error: {db_err}")
turntables = [] turntables = []
for gid in tc.list_cached_group_ids(output_dir): for gid in tc.list_cached_group_ids(output_dir):
is_orphaned = False
is_active = False
if gid.startswith("file_"):
fname = gid[5:]
if all_db_filenames and fname not in all_db_filenames:
is_orphaned = True
elif active_db_filenames and fname in active_db_filenames:
is_active = True
else:
if all_db_gids and gid not in all_db_gids:
is_orphaned = True
elif active_db_gids and gid in active_db_gids:
is_active = True
# 1. Truly orphaned turntable caches (no associated base images or files in DB)
if is_orphaned:
print(f"[static] Deleting truly orphaned turntable cache and DB records for group/file {gid}")
tc.delete_state(output_dir, gid)
try:
conn = database.get_db_connection()
cur = conn.cursor()
cur.execute("DELETE FROM person WHERE group_id = %s", (gid,))
conn.commit()
cur.close()
database._put_db_connection(conn)
except Exception as del_err:
print(f"[static] Failed to clean DB records for orphaned group/file {gid}: {del_err}")
continue
# 2. Inactive turntable caches (associated group/file is fully archived or hidden)
if not is_active:
# Keep cache on disk but do not write to turntables.json (hide from UI)
continue
state = tc.load_state(output_dir, gid) state = tc.load_state(output_dir, gid)
if not state: if not state:
continue continue
@@ -3496,6 +3547,7 @@ def autocrop_image(filename: str):
cmin, cmax = np.where(cols)[0][[0, -1]] cmin, cmax = np.where(cols)[0][[0, -1]]
cropped = img.crop((cmin, rmin, cmax + 1, rmax + 1)) cropped = img.crop((cmin, rmin, cmax + 1, rmax + 1))
cropped.save(path, format="PNG") cropped.save(path, format="PNG")
_invalidate_static()
return {"status": "success", "filename": filename, "box": [int(cmin), int(rmin), int(cmax+1), int(rmax+1)]} return {"status": "success", "filename": filename, "box": [int(cmin), int(rmin), int(cmax+1), int(rmax+1)]}
@@ -3558,8 +3610,7 @@ def manual_crop_image(filename: str, req: CropRequest):
cropped = img.crop((x1, y1, x2, y2)) cropped = img.crop((x1, y1, x2, y2))
fmt = "PNG" if path.lower().endswith(".png") else "JPEG" fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
cropped.save(path, format=fmt) cropped.save(path, format=fmt)
if req.as_copy: _invalidate_static()
_invalidate_static()
return {"status": "success", "filename": filename, "new_filename": new_filename, return {"status": "success", "filename": filename, "new_filename": new_filename,
"new_url": f"/output/{new_filename}", "as_copy": req.as_copy, "new_url": f"/output/{new_filename}", "as_copy": req.as_copy,
"box": [x1, y1, x2, y2]} "box": [x1, y1, x2, y2]}
@@ -3679,8 +3730,7 @@ def pad_image(filename: str, req: PadRequest):
if req.fill == "transparent": if req.fill == "transparent":
fmt = "PNG" # JPEG cannot store alpha fmt = "PNG" # JPEG cannot store alpha
padded.save(path, format=fmt) padded.save(path, format=fmt)
if req.as_copy: _invalidate_static()
_invalidate_static()
return { return {
"status": "success", "filename": filename, "new_filename": new_filename, "status": "success", "filename": filename, "new_filename": new_filename,
"new_url": f"/output/{new_filename}", "as_copy": req.as_copy, "new_url": f"/output/{new_filename}", "as_copy": req.as_copy,
@@ -3712,6 +3762,7 @@ def rotate_image(filename: str, req: RotateRequest):
img = Image.open(path).transpose(cw_to_transpose[deg]) img = Image.open(path).transpose(cw_to_transpose[deg])
fmt = "PNG" if path.lower().endswith(".png") else "JPEG" fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
img.save(path, format=fmt) img.save(path, format=fmt)
_invalidate_static()
return {"status": "success", "filename": filename, "degrees": deg} return {"status": "success", "filename": filename, "degrees": deg}

View File

@@ -101,7 +101,10 @@
<div id="toast"></div> <div id="toast"></div>
<script> <script>
const API = window.location.origin; let API = window.location.origin;
if (API === 'null' || API.startsWith('file://')) {
API = 'http://127.0.0.1:8500';
}
function showToast(msg) { function showToast(msg) {
const t = document.getElementById('toast'); const t = document.getElementById('toast');