diff --git a/AGENTS.md b/AGENTS.md index 67eb793..a9cb5d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,15 +78,27 @@ FastAPI :8500 (edit_api.py) | `POST` | `/images/{filename}/unarchive` | Restore archived image | | `POST` | `/images/{filename}/set-hidden` | Toggle studio visibility | | `DELETE` | `/images/{filename}` | Delete image + DB record | -| `POST` | `/images/{filename}/crop` | Crop to pixel rect (in-place) | -| `POST` | `/autocrop/{filename}` | Auto-trim transparent border | +| `POST` | `/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 → `.nobg.png` sidecar | +| `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=` (0–1) from wireframe video | | `GET` | `/groups` | List all groups with members | | `GET` | `/names` | Map `filename → person name` | | `GET/POST` | `/config` | Read / write `config.json` | +| `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` | +| `GET` | `/pose/check` | Report pose-estimator availability + backend | +| `POST` | `/images/{filename}/pose` | Estimate COCO-17 body keypoints (caches descriptor for similar-search) | +| `GET` | `/pose/similar/{filename}` | Rank library images by pose similarity to this image | +| `POST` | `/pose/similar` | Rank library images by similarity to a supplied (edited) skeleton | +| `POST` | `/pose/index` | Build pose descriptors for the whole library (daemon thread) | +| `GET` | `/pose/index/status` | Poll pose-index build progress | --- @@ -232,8 +244,8 @@ Single-page application (~5000 lines). No build step — pure HTML/CSS/JS. | Tab | Purpose | |-----|---------| -| Generate | Camera angle picker, pose selector, wireframe guide, prompt override, fine-tune textarea, batch submit | -| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete actions, manual crop | +| Generate | Camera angle picker, pose selector (with inline edit/add/delete → `poses.md`), wireframe guide, auto-growing prompt override with history autocomplete, fine-tune textareas, multi-ref thumbnails (click to deselect), batch submit | +| Info | Metadata, preferred toggle, face-book thumbnail, hide/archive/delete, manual crop (copy or in-place), rotate ±90°, No-BG / Invert-α, duplicate, Pose preview (draggable + similar-pose search) | | Faceswap | Source face selector, target video, preview quality toggle | | Segment | SAM2 and rembg BG removal buttons | | Scenery | Scenery video reference for generation | @@ -258,6 +270,35 @@ The studio Generate tab shows a video picker + time scrubber. On generation: --- +## Pose Tools (2D body pose) + +A posenet-style toolset built on a feature-detected estimator. Preferred backend is +**rtmlib** (RTMPose, ONNX via the already-installed `onnxruntime`); **mediapipe** is a +fallback. If neither is installed the endpoints return `501` and the UI shows an install hint. + +- **Install (A6000 venv):** `~/comfyui/venv/bin/pip install rtmlib`. Models (~150 MB) auto- + download to `~/.cache/rtmlib` on first use. +- **CUDA libs:** `onnxruntime-gpu` needs the venv's `nvidia-*-cu12` libs on the loader path. + `a6000-comfy/start_api.sh` adds them to `LD_LIBRARY_PATH` at launch (torch finds them via + RPATH, onnxruntime does not). + +**Output format:** COCO-17 keypoints (`POSE_KEYPOINT_NAMES`) + `POSE_SKELETON` edge list, +returned in image pixels with per-joint score. + +**Studio overlay (`car.html`):** the **Pose** button overlays the skeleton on the viewer +(letterbox-aware canvas). Joints are **draggable** to refine/explore a pose. Overlay toolbar: +*Reset* (re-detect) and *Similar pose* (rank the library by the current — possibly edited — +skeleton). Results render as a thumbnail strip; clicking one opens that image's group. + +**Similarity model (`edit_api.py`):** +- `_pose_descriptor(keypoints)` → translation/scale-invariant vector: centered on hip midpoint + (fallback shoulders), scaled by torso length, 17×(x,y) + visibility mask. `None` if < 6 joints. +- `_pose_distance(a, b)` → weighted L2 over jointly-visible joints, taking the min of the direct + and the **left-right-mirrored** comparison so mirrored poses match. +- **Pose index:** descriptors are cached in `/_data/poses_index.json`. It fills + incrementally whenever `/images/{f}/pose` runs, or in bulk via `/pose/index` (daemon thread, + batched writes every 50, progress via `/pose/index/status`). + ## File Layout ``` diff --git a/a6000-comfy/start_api.sh b/a6000-comfy/start_api.sh index 7957419..a8c523e 100755 --- a/a6000-comfy/start_api.sh +++ b/a6000-comfy/start_api.sh @@ -6,6 +6,12 @@ source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh" source "$VENV/bin/activate" cd "$API_DIR" +# onnxruntime-gpu (used by the body-pose estimator) needs the CUDA runtime libs that +# ship inside the venv's nvidia-*-cu12 pip packages on the loader path. torch finds +# them via RPATH, but onnxruntime does not — so put them on LD_LIBRARY_PATH here. +NV_LIBS=$(find "$VENV"/lib/python*/site-packages/nvidia -maxdepth 2 -name lib -type d 2>/dev/null | paste -sd:) +[ -n "$NV_LIBS" ] && export LD_LIBRARY_PATH="${NV_LIBS}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export COMFY_URL="http://127.0.0.1:8188" export HOST="0.0.0.0" export PORT="8500" diff --git a/backlog.md b/backlog.md index c171802..287abc1 100644 --- a/backlog.md +++ b/backlog.md @@ -1,3 +1,27 @@ +# Backlog + +## Shipped (recent) + +Studio / Generate UX: +- ✅ Crop reaches the bottom edge (toolbar moved to top); choose copy-vs-replace ("Save as copy") +- ✅ Paste-to-group image shows immediately (no refresh needed) +- ✅ Generated images store the prompt used (and pose name) in the DB +- ✅ Edit / add / delete poses from the Generate tab (persists to `poses.md`) +- ✅ flag beta poses in ui — beta badge + editable beta flag +- ✅ Custom prompt field auto-grows; keeps `list="promptHistory"` autocomplete +- ✅ Remove-BG persists `has_background` + refreshes static data (survives reload) +- ✅ Invert-α button (fixes wrong-segment background removal) +- ✅ zien van de geselecteerde images in de prompt — multi-ref thumbs visible; click to deselect +- ✅ Landing-page group merge syncs immediately (synchronous static write) +- ✅ Rotate ±90° in the studio + +Pose tools (rtmlib / RTMPose, 2D): +- ✅ Pose skeleton preview overlay in studio +- ✅ Draggable joints to refine/explore a pose +- ✅ Find similar poses across the library (translation/scale/mirror-invariant; `poses_index.json`) + +## Open + fix queen.mp moet gegenereerd worden door de htmlbuilder, niet via de webserver images.json @@ -7,22 +31,21 @@ groups.json config videos -flag beta poses in ui - extract a frame from a clip (scenery) faceswap defaults nothing enabled, see jobs even when refresh pose bestaat gewoon uit meerdere delen, camera, scenery + addition -focus op head + sam2.hyria seg + mesh - -zien van de geselecteerde images in de prompt in studio view +focus op head + sam2.hyria seg + mesh (3D / mesh — pose editor is currently 2D only) + sam2 saifrail, connected pixels from mid? als invertor? -poses assignment bij de camera positie generaties +poses assignment bij de camera positie generaties poses: hyper realistic, lighting, detailled skin etc.. see last generations for better results. -check 7b uncensored for automation +create disk 128gb 70b dolphin uncencored op server. +rating based pose, thumbs up/down find good/bad poses easier + (pairs well with the new pose index — could weight similar-pose results by rating) -create disk 128gb 70b dolphin uncencored op server. \ No newline at end of file +when refresh page, we lose track of current jobs running. diff --git a/tour-comfy/bootstrap.sh b/tour-comfy/bootstrap.sh deleted file mode 100755 index 8853be9..0000000 --- a/tour-comfy/bootstrap.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash -# One-time (idempotent) host setup for the Qwen-Image-Edit service. -# Runs as the service user (NO sudo). Safe to re-run: existing pieces are skipped. -# -# Builds, under the project BASE (the parent of this api/ dir): -# venv/ torch 2.3.1+rocm5.7 + ComfyUI deps (gfx906 / ROCm 5.7) -# ComfyUI/ pinned to v0.3.77 + ComfyUI-GGUF custom node -# ComfyUI/models/{unet,text_encoders,vae}/ the v23 Q8 GGUF + encoder + vae -set -e - -source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh" -GGUF_NODE="$COMFY/custom_nodes/ComfyUI-GGUF" -COMFY_TAG="v0.3.77" # newest tag that runs on torch 2.3.1 (no comfy_kitchen) - -echo "[bootstrap] BASE=$BASE VENV=$VENV" - -# --- ComfyUI (pinned) ------------------------------------------------------- -if [ ! -d "$COMFY/.git" ]; then - echo "[bootstrap] cloning ComfyUI @ $COMFY_TAG ..." - git clone --depth 1 --branch "$COMFY_TAG" \ - https://github.com/comfyanonymous/ComfyUI.git "$COMFY" -fi - -# --- venv + python deps ----------------------------------------------------- -if [ ! -d "$VENV" ]; then - echo "[bootstrap] creating venv at $VENV ..." - python3 -m venv "$VENV" -fi -source "$VENV/bin/activate" -python -m pip install --upgrade pip wheel -echo "[bootstrap] installing torch (rocm5.7) ..." -pip install torch==2.3.1+rocm5.7 torchvision==0.18.1+rocm5.7 \ - --index-url https://download.pytorch.org/whl/rocm5.7 -echo "[bootstrap] installing ComfyUI requirements ..." -pip install -r "$COMFY/requirements.txt" - -# --- ComfyUI-GGUF custom node ---------------------------------------------- -if [ ! -d "$GGUF_NODE" ]; then - echo "[bootstrap] cloning ComfyUI-GGUF ..." - git clone --depth 1 https://github.com/city96/ComfyUI-GGUF.git "$GGUF_NODE" -fi -pip install -r "$GGUF_NODE/requirements.txt" || pip install gguf - -# --- API deps --------------------------------------------------------------- -pip install fastapi "uvicorn[standard]" websocket-client python-multipart pillow requests - -# --- models (resume-safe; skipped if already complete) ---------------------- -M="$COMFY/models" -mkdir -p "$M/unet" "$M/text_encoders" "$M/vae" -dl() { # url dest - if [ -s "$2" ]; then echo "[bootstrap] have $(basename "$2")"; return; fi - echo "[bootstrap] downloading $(basename "$2") ..." - wget -c -q -O "$2" "$1" -} -dl "https://huggingface.co/Novice25/Qwen-Image-Edit-Rapid-AIO-GGUF/resolve/main/v23/Qwen-Rapid-NSFW-v23_Q8_0.gguf" \ - "$M/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf" -dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" \ - "$M/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" -dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" \ - "$M/vae/qwen_image_vae.safetensors" - -echo "[bootstrap] verifying torch + GPU ..." -python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO GPU')" -echo "[bootstrap] BOOTSTRAP_DONE" diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 54656a1..d75f6ea 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -1459,6 +1459,13 @@ font-size: 9px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px; display: flex; align-items: center; justify-content: center; line-height: 1; } + .sb-gen-refs .sb-gen-ref-x { + position: absolute; top: -4px; right: -4px; background: #dc2626; color: #fff; + font-size: 11px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px; + display: none; align-items: center; justify-content: center; line-height: 1; + } + .sb-gen-refs .sb-gen-ref:hover .sb-gen-ref-x { display: flex; } + .sb-gen-refs .sb-gen-ref:hover img { border-color: #dc2626; } #studioAngleBar { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%); display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; @@ -1490,7 +1497,9 @@ } .sb-template-card:hover { border-color: #7c3aed; transform: scale(1.02); } .sb-template-card.selected { border-color: #22c55e; } - .sb-template-card video { width:100%; height:100%; object-fit:cover; pointer-events:none; } + .sb-template-card video, + .sb-template-card img { width:100%; height:100%; object-fit:cover; pointer-events:none; } + .sb-template-card video { position:absolute; inset:0; } .sb-template-label { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent,rgba(0,0,0,0.82)); @@ -1845,11 +1854,13 @@
+ + Download @@ -2330,6 +2341,13 @@ document.getElementById('studio').classList.remove('open'); document.body.style.overflow = ''; stopSlideshow(); + // Release every streaming/decoding video so closing the studio frees memory + // and stops background network streams (template clips + scenery preview). + _tplStopAll(); + ['sceneVideoEl', 'lbVideo', 'cltVideoPreview'].forEach(id => { + const v = document.getElementById(id); + if (v) { v.pause(); v.removeAttribute('src'); v.load(); } + }); } function toggleSlideshow() { @@ -2391,6 +2409,8 @@ function updateStudio() { const fname = lbNames[lbIdx]; + if (document.getElementById('poseCanvas')) _removePoseOverlay(); // skeleton is per-image + document.getElementById('poseResults')?.remove(); _fsModelFilename = fname; // keep faceswap/scenery/segment in sync const isVid = isVideo(fname) || fileContentType[fname] === 'video'; const lbImgEl = document.getElementById('lbImg'); @@ -2442,6 +2462,11 @@ if (faceswapBtn)faceswapBtn.style.display = !isVid ? '' : 'none'; const cropBtn = document.getElementById('lbCropBtn'); if (cropBtn) cropBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none'; + // Invert α is only meaningful on a transparent image (after background removal). + const invAlphaBtn = document.getElementById('lbInvertAlphaBtn'); + if (invAlphaBtn) invAlphaBtn.style.display = (!isVid && fileHasBg[fname] === false) ? '' : 'none'; + const poseBtn = document.getElementById('lbPoseBtn'); + if (poseBtn) poseBtn.style.display = isVid ? 'none' : ''; const archiveBtn = document.getElementById('lbArchiveBtn'); if (archiveBtn) archiveBtn.textContent = fileArchived[fname] ? 'Restore' : 'Archive'; @@ -2767,7 +2792,6 @@ showToast('Rotated', 'success'); lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now(); updateStudio(); - refreshNow(); } else { showToast('Rotate failed: ' + await r.text(), 'error'); } @@ -2775,6 +2799,239 @@ btns.forEach(b => { if (b) b.disabled = false; }); } + // --- body-pose skeleton preview (interactive) --- + const POSE_MIN_SCORE = 0.3; + const POSE_KP_COLORS = ['#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6','#a855f7']; + + function _removePoseOverlay() { + document.getElementById('poseCanvas')?.remove(); + document.getElementById('poseToolbar')?.remove(); + window._poseState = null; + const btn = document.getElementById('lbPoseBtn'); + if (btn) btn.classList.remove('primary'); + } + + async function lbTogglePose() { + if (document.getElementById('poseCanvas')) { _removePoseOverlay(); return; } + const fname = lbNames[lbIdx]; + if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image', 'info'); return; } + const btn = document.getElementById('lbPoseBtn'); + if (btn) { btn.disabled = true; btn.textContent = '…'; } + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' }); + if (r.status === 501) { + showToast('Pose estimator not installed on the server (pip install rtmlib onnxruntime)', 'info', 6000); + } else if (r.ok) { + const d = await r.json(); + if (!d.people || !d.people.length) showToast('No person detected', 'info'); + else { _initPoseOverlay(d, fname); showToast(`Pose (${d.backend}) — drag joints to edit`, 'success'); } + } else { + showToast('Pose failed: ' + await r.text(), 'error'); + } + } catch (e) { showToast('Pose failed: ' + e, 'error'); } + if (btn) { btn.disabled = false; btn.textContent = 'Pose'; } + } + + function _initPoseOverlay(data, fname) { + _removePoseOverlay(); + const viewer = document.getElementById('studioViewer'); + const img = document.getElementById('lbImg'); + if (!viewer || !img) return; + const canvas = document.createElement('canvas'); + canvas.id = 'poseCanvas'; + canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;cursor:grab;z-index:90;'; + viewer.appendChild(canvas); + canvas.width = viewer.clientWidth; + canvas.height = viewer.clientHeight; + + // object-fit:contain letterbox mapping image px ↔ canvas px. + const iW = data.width, iH = data.height; + const scale = Math.min(canvas.width / iW, canvas.height / iH); + const st = window._poseState = { + fname, canvas, ctx: canvas.getContext('2d'), + people: data.people.map(p => p.map(k => k.slice())), // deep copy (editable) + skeleton: data.skeleton, width: iW, height: iH, + scale, offX: (canvas.width - iW * scale) / 2, offY: (canvas.height - iH * scale) / 2, + drag: null, + }; + + // Floating toolbar (top-left so it doesn't fight the crop bar on the right). + const bar = document.createElement('div'); + bar.id = 'poseToolbar'; + bar.style.cssText = 'position:absolute;top:8px;left:8px;display:flex;gap:6px;z-index:102;'; + bar.innerHTML = + '' + + ''; + viewer.appendChild(bar); + + canvas.addEventListener('mousedown', _posePointerDown); + canvas.addEventListener('mousemove', _posePointerMove); + window.addEventListener('mouseup', _posePointerUp); + _drawPose(); + const btn = document.getElementById('lbPoseBtn'); + if (btn) btn.classList.add('primary'); + } + + function _drawPose() { + const st = window._poseState; if (!st) return; + const { ctx, canvas, scale, offX, offY } = st; + const cx = x => offX + x * scale, cy = y => offY + y * scale; + ctx.clearRect(0, 0, canvas.width, canvas.height); + st.people.forEach((kpts, pi) => { + const color = POSE_KP_COLORS[pi % POSE_KP_COLORS.length]; + ctx.lineWidth = 3; ctx.strokeStyle = color; + st.skeleton.forEach(([a, b]) => { + const pa = kpts[a], pb = kpts[b]; + if (!pa || !pb || pa[2] < POSE_MIN_SCORE || pb[2] < POSE_MIN_SCORE) return; + ctx.beginPath(); ctx.moveTo(cx(pa[0]), cy(pa[1])); ctx.lineTo(cx(pb[0]), cy(pb[1])); ctx.stroke(); + }); + kpts.forEach((p, ki) => { + if (!p || p[2] < POSE_MIN_SCORE) return; + const hot = st.drag && st.drag.pi === pi && st.drag.ki === ki; + ctx.beginPath(); ctx.arc(cx(p[0]), cy(p[1]), hot ? 7 : 5, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; ctx.fill(); + ctx.lineWidth = 2; ctx.strokeStyle = color; ctx.stroke(); + }); + }); + } + + function _poseHitTest(mx, my) { + const st = window._poseState; if (!st) return null; + const cx = x => st.offX + x * st.scale, cy = y => st.offY + y * st.scale; + let best = null, bestD = 14 * 14; // ~14px grab radius + st.people.forEach((kpts, pi) => kpts.forEach((p, ki) => { + if (!p || p[2] < POSE_MIN_SCORE) return; + const dx = mx - cx(p[0]), dy = my - cy(p[1]), d = dx * dx + dy * dy; + if (d < bestD) { bestD = d; best = { pi, ki }; } + })); + return best; + } + + function _poseEvtXY(e) { + const r = window._poseState.canvas.getBoundingClientRect(); + return [e.clientX - r.left, e.clientY - r.top]; + } + function _posePointerDown(e) { + const st = window._poseState; if (!st) return; + const [mx, my] = _poseEvtXY(e); + const hit = _poseHitTest(mx, my); + if (hit) { st.drag = hit; st.canvas.style.cursor = 'grabbing'; _drawPose(); e.preventDefault(); } + } + function _posePointerMove(e) { + const st = window._poseState; if (!st) return; + if (!st.drag) { + const [mx, my] = _poseEvtXY(e); + st.canvas.style.cursor = _poseHitTest(mx, my) ? 'grab' : 'default'; + return; + } + const [mx, my] = _poseEvtXY(e); + const p = st.people[st.drag.pi][st.drag.ki]; + p[0] = Math.max(0, Math.min(st.width, (mx - st.offX) / st.scale)); + p[1] = Math.max(0, Math.min(st.height, (my - st.offY) / st.scale)); + if (p[2] < POSE_MIN_SCORE) p[2] = 1.0; // dragging a hidden joint makes it visible + _drawPose(); + } + function _posePointerUp() { + const st = window._poseState; if (!st || !st.drag) return; + st.drag = null; st.canvas.style.cursor = 'grab'; _drawPose(); + } + + async function lbPoseReset() { + const st = window._poseState; if (!st) return; + const fname = st.fname; + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pose`, { method: 'POST' }); + if (r.ok) { const d = await r.json(); if (d.people?.length) _initPoseOverlay(d, fname); } + } catch (e) { showToast('Reset failed: ' + e, 'error'); } + } + + async function lbFindSimilarPose() { + const st = window._poseState; if (!st) return; + // Use the (possibly edited) primary skeleton. + const kpts = st.people[0]; + const btn = event?.target; + if (btn) btn.disabled = true; + try { + const r = await fetch(`${API}/pose/similar`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keypoints: kpts, width: st.width, height: st.height, limit: 12 }), + }); + if (r.ok) { + const d = await r.json(); + _renderPoseResults(d.similar || []); + } else if (r.status === 400) { + showToast('Pose too sparse — drag more joints into place', 'info'); + } else { + showToast('Similar failed: ' + await r.text(), 'error'); + } + } catch (e) { showToast('Similar failed: ' + e, 'error'); } + if (btn) btn.disabled = false; + } + + function _renderPoseResults(items) { + document.getElementById('poseResults')?.remove(); + const viewer = document.getElementById('studioViewer'); + if (!viewer) return; + const panel = document.createElement('div'); + panel.id = 'poseResults'; + panel.style.cssText = 'position:absolute;left:0;right:0;bottom:0;z-index:103;background:rgba(0,0,0,0.85);' + + 'padding:8px;display:flex;gap:8px;overflow-x:auto;align-items:center;'; + if (!items.length) { + panel.innerHTML = 'No similar poses found — try building the pose index.' + + '' + + ''; + } else { + panel.innerHTML = 'Similar poses:' + + items.map(it => { + const u = IMAGE_FOLDER + it.filename + '?t=' + Date.now(); + return `
+ +
${it.distance}
`; + }).join('') + + ''; + } + viewer.appendChild(panel); + } + + function openPoseResult(gid, fname) { + _removePoseOverlay(); + document.getElementById('poseResults')?.remove(); + if (gid && groupData.has(gid)) { + const i = groupData.get(gid).names.indexOf(fname); + openStudio(gid, i >= 0 ? i : 0); // openStudio sets lbUrls/lbNames/lbIdx + updateStudio + } else { + const i = lbNames.indexOf(fname); + if (i >= 0) { lbIdx = i; updateStudio(); } + else showToast('Open its group from the gallery: ' + fname, 'info', 5000); + } + } + + async function lbBuildPoseIndex() { + try { + const r = await fetch(`${API}/pose/index`, { method: 'POST' }); + if (r.status === 501) { showToast('Pose estimator not installed', 'info'); return; } + if (!r.ok) { showToast('Index failed: ' + await r.text(), 'error'); return; } + showToast('Building pose index…', 'info'); + _pollPoseIndex(); + } catch (e) { showToast('Index failed: ' + e, 'error'); } + } + + async function _pollPoseIndex() { + try { + const r = await fetch(`${API}/pose/index/status`); + if (r.ok) { + const s = await r.json(); + if (s.running) { + showToast(`Pose index: ${s.done}/${s.total}…`, 'info', 2500); + setTimeout(_pollPoseIndex, 2000); + } else { + showToast(`Pose index ready (${s.indexed} images)`, 'success'); + } + } + } catch (e) { /* stop polling */ } + } + function startManualCrop() { const fname = lbNames[lbIdx]; if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; } @@ -2930,23 +3187,54 @@ async function lbRemoveBg() { const fname = lbNames[lbIdx]; - showToast(`Removing background for ${fname}...`); + const btn = document.getElementById('lbNoBgBtn'); + if (btn) { btn.disabled = true; btn.textContent = 'Removing…'; } + showToast('Removing background…'); try { const r = await fetch(`${API}/remove-background/${fname}`, { method: 'POST' }); if (r.ok) { - showToast(`Background removed for ${fname}`, 'success'); - // Refresh current image in lightbox by adding timestamp - const img = document.getElementById('lbImg'); - img.src = img.src.split('?')[0] + '?t=' + Date.now(); - refreshNow(); + showToast('Background removed', 'success'); + fileHasBg[fname] = false; + _bustStudioImage(fname); // cache-busts viewer + calls updateStudio() } else { showToast(`Failed to remove background for ${fname}`, 'error'); } } catch (e) { showToast(`Failed to remove background: ${e}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'No BG'; } } } + async function lbInvertAlpha() { + const fname = lbNames[lbIdx]; + if (!fname || !/\.(png|webp)$/i.test(fname)) { showToast('Invert needs a transparent PNG', 'info'); return; } + const btn = document.getElementById('lbInvertAlphaBtn'); + if (btn) btn.disabled = true; + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/invert-alpha`, { method: 'POST' }); + if (r.ok) { + showToast('Transparency inverted', 'success'); + fileHasBg[fname] = false; + _bustStudioImage(fname); + refreshNow(); + } else { + showToast('Invert failed: ' + await r.text(), 'error'); + } + } catch (e) { showToast('Invert failed: ' + e, 'error'); } + if (btn) btn.disabled = false; + } + + // Force the studio image (filmstrip + main viewer) to reload from disk. + function _bustStudioImage(fname) { + const t = Date.now(); + const i = lbNames.indexOf(fname); + if (i >= 0) lbUrls[i] = IMAGE_FOLDER + fname + '?t=' + t; + const img = document.getElementById('lbImg'); + if (img && lbNames[lbIdx] === fname) img.src = IMAGE_FOLDER + fname + '?t=' + t; + updateStudio(); + } + async function deleteGroup(el, event) { if (event) event.stopPropagation(); const gid = el.closest('.image-card').dataset.group; @@ -3018,8 +3306,9 @@ showToast(`Error uploading ${fileName}: ${e}`, 'error'); } } - // Trigger refresh - refreshNow(); + // The source image is registered by a background task slightly after /upload + // returns; one debounced refresh picks it up without piling up concurrent loads. + setTimeout(refreshNow, 800); } // --- clipboard paste support --- @@ -3131,6 +3420,8 @@ } async function loadImages() { + if (loadImages._inFlight) return; + loadImages._inFlight = true; const gallery = document.getElementById('gallery'); const statusDot = document.getElementById('statusDot'); statusDot.classList.add('updating'); @@ -3204,6 +3495,7 @@
`; } statusDot.classList.remove('updating'); + loadImages._inFlight = false; return; } @@ -3311,14 +3603,27 @@ 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; - lbIdx = newIdx >= 0 ? newIdx : Math.min(lbIdx, fresh.names.length - 1); + // Keep lbIdx stable when current image not yet in fresh data + // (e.g. duplicate just created — server JSON may lag by the debounce window). + if (newIdx >= 0) { + lbIdx = newIdx; + } else if (lbIdx >= fresh.names.length) { + lbIdx = fresh.names.length - 1; + } + // Re-map multi-ref selections to new positions by filename + _sbRefIndices = oldRefNames + .map(n => fresh.names.indexOf(n)) + .filter(i => i >= 0); updateStudio(); } } statusDot.classList.remove('updating'); + loadImages._inFlight = false; } // Discover images by trying common patterns (fallback for file:// protocol) @@ -3365,8 +3670,13 @@ if (!_isStudioOpen()) refreshNow(); } + // Debounced refresh — coalesces multiple rapid calls into one loadImages() run. + // Direct callers that need a response (pollJob completion, etc.) still use + // loadImages() directly; everything else should go through refreshNow(). + let _refreshTimer = null; function refreshNow() { - loadImages(); + clearTimeout(_refreshTimer); + _refreshTimer = setTimeout(loadImages, 80); } const API = 'http://127.0.0.1:8500'; @@ -3711,7 +4021,7 @@ const data = groupData.get(lbCurrentGid); if (!data) return; const filename = data.names[lbIdx]; - const btn = document.querySelector('#lightbox .lb-btn[onclick="tagCurrentImage()"]'); + const btn = document.querySelector('button[onclick="tagCurrentImage()"]'); if (btn) btn.textContent = '…'; try { const r = await fetch(`${API}/tag`, { @@ -3732,7 +4042,7 @@ showToast(`Tag failed: ${err}`, 'info'); } } catch (e) { showToast('API not reachable', 'info'); } - if (btn) btn.textContent = 'Tag'; + if (btn) btn.textContent = 'Re-tag'; } async function extractCurrentImage() { @@ -3840,17 +4150,24 @@ } if (!document.getElementById('studio').classList.contains('open')) return; if (tag === 'INPUT' || tag === 'TEXTAREA') return; - if (e.key === 'ArrowLeft') { lbNav(-1); e.preventDefault(); } - if (e.key === 'ArrowRight') { lbNav(1); e.preventDefault(); } - if (e.key === 'Home') { - lbIdx = 0; - updateStudio(); - e.preventDefault(); + // Throttle filmstrip navigation — ignore if last nav was < 80 ms ago. + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + const now = Date.now(); + if (now - (document._lbNavLast || 0) >= 80) { + document._lbNavLast = now; + lbNav(e.key === 'ArrowLeft' ? -1 : 1); + } + e.preventDefault(); } - if (e.key === 'End') { - lbIdx = lbUrls.length - 1; - updateStudio(); - e.preventDefault(); + if (e.key === 'Home') { + lbIdx = 0; + updateStudio(); + e.preventDefault(); + } + if (e.key === 'End') { + lbIdx = lbUrls.length - 1; + updateStudio(); + e.preventDefault(); } if (e.key === ' ') { const vid = document.getElementById('lbVideo'); @@ -3859,22 +4176,24 @@ e.preventDefault(); } } - if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { lbToggleHidden(); } - if (e.key === 'F' || e.key === 'f') { - lbSetPreferred(); - e.preventDefault(); + // Guard async actions against concurrent executions via a simple inflight flag. + if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { + if (!document._lbHideInFlight) { document._lbHideInFlight = true; lbToggleHidden().finally(() => { document._lbHideInFlight = false; }); } } - if (e.key === 'Delete') { - lbArchive(); - e.preventDefault(); + if (e.key === 'F' || e.key === 'f') { + lbSetPreferred(); + e.preventDefault(); } - if (e.key === 'ArrowUp') { - lbMoveInGroup(-1); - e.preventDefault(); + if (e.key === 'Delete') { + lbArchive(); + e.preventDefault(); } - if (e.key === 'ArrowDown') { - lbMoveInGroup(1); - e.preventDefault(); + // ArrowUp/Down move images within the group — debounce rapid keypresses. + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + clearTimeout(document._lbMoveTimer); + const d = e.key === 'ArrowUp' ? -1 : 1; + document._lbMoveTimer = setTimeout(() => lbMoveInGroup(d), 120); + e.preventDefault(); } }); }); @@ -3955,7 +4274,7 @@ async function setAsPreferred(filename, btn) { try { - const r = await fetch(`/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'}); + 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 @@ -4008,6 +4327,7 @@ } function switchSidebarTab(tab, initial) { + _tplStopAll(); // release any hovered template video before re-rendering panels _activeSidebarTab = tab; localStorage.setItem('studioSidebarTab', tab); document.querySelectorAll('#studioSidebar .sb-tab').forEach(t => @@ -4143,9 +4463,9 @@ const fn = lbNames[idx] || ''; const u = lbUrls[idx] || ''; const thumb = isVideo(fn) ? posterFor(u) : u; - return `
${pos+1}
`; + return `
${pos+1}
×
`; }).join(''); - html += `
References · Shift+Click filmstrip to change
+ html += `
References · Shift+Click filmstrip or click a thumb to remove
${refThumbs}
`; // With 2–3 refs, let the user choose how the prompt is applied. if (_sbRefIndices.length >= 2) { @@ -4326,6 +4646,15 @@ renderSidebarGenerate(); } + // Remove a reference by clicking its thumbnail above the custom prompt — no page + // refresh needed (previously refs could only be cleared via Shift+Click filmstrip). + function sbRemoveRef(idx) { + const pos = _sbRefIndices.indexOf(idx); + if (pos >= 0) _sbRefIndices.splice(pos, 1); + updateStudio(); + if (_activeSidebarTab === 'generate') renderSidebarGenerate(); + } + function sbSelectWireframe(val) { _sbWireframeRef = val; renderSidebarGenerate(); @@ -4543,6 +4872,53 @@ } } + // ---- template video cards (faceswap / scenery) ---- + // The wireframe library is large (dozens of files, >1 GB total) and lives on a + // network mount. Rendering every card as an autoplaying