diff --git a/backlog.md b/backlog.md index 90606b8..a5d5520 100644 --- a/backlog.md +++ b/backlog.md @@ -20,6 +20,7 @@ Pose tools (rtmlib / RTMPose, 2D): - ✅ Draggable joints to refine/explore a pose - ✅ Find similar poses across the library (translation/scale/mirror-invariant; `poses_index.json`) - ✅ Gesture presets in pose toolbar (T-pose, wave, arms raised, hands on hips, turn ±45°) +- ✅ "Generate ▸" in pose toolbar — renders skeleton as OpenPose image → Qwen image2 guide → new generation - ✅ "Orbit all" button in Generate tab selects all 6 absolute camera angles at once Scenery: diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 4f1c5a5..64a6626 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -1895,6 +1895,7 @@ + Download @@ -2926,7 +2927,9 @@ + '
' + ' ' + ' ' - + '
'; + + '' + + '' + + ''; viewer.appendChild(bar); _buildGestureMenu(); @@ -2997,9 +3000,10 @@ const g = GESTURE_PRESETS[idx]; const W = st.width, H = st.height; st.people[0] = g.kpts.map(([nx, ny, c]) => [nx * W, ny * H, c]); + st._gestureName = g.name; // remember for generation _drawPose(); document.getElementById('gestureMenuPanel').style.display = 'none'; - showToast(`Gesture: ${g.name}`, 'success'); + showToast(`Gesture: ${g.name} — click Generate ▸ to create`, 'success'); } function _drawPose() { @@ -3098,6 +3102,154 @@ if (btn) btn.disabled = false; } + async function lbGenerateWithPose() { + const st = window._poseState; + if (!st || !st.people.length) { showToast('No pose overlay active — click Pose first', 'info'); return; } + const fname = st.fname; + if (!fname) { showToast('No image active', 'info'); return; } + const btn = document.getElementById('poseGenBtn'); + if (btn) { btn.disabled = true; btn.textContent = '…'; } + try { + const payload = { + model_filename: fname, + keypoints: st.people[0], + width: st.width, + height: st.height, + gesture_name: st._gestureName || null, + seed: -1, + }; + const r = await fetch(`${API}/generate-with-pose`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!r.ok) { showToast('Generate failed: ' + await r.text(), 'error'); return; } + const { job_id } = await r.json(); + showToast('Pose generation started…', 'info'); + if (btn) btn.textContent = 'Generating…'; + const poll = () => setTimeout(async () => { + try { + const jr = await fetch(`${API}/batch/${job_id}`); + if (!jr.ok) { poll(); return; } + const job = await jr.json(); + if (job.status === 'running') { poll(); return; } + if (btn) { btn.disabled = false; btn.textContent = 'Generate ▸'; } + if (job.status === 'done') { + showToast('Pose generation done!', 'success'); + _followLatestGid = lbCurrentGid; + refreshNow(); + } else { + showToast('Pose generation failed: ' + (job.error || 'unknown'), 'error'); + } + } catch (e) { poll(); } + }, 2500); + poll(); + } catch (e) { + showToast('Generate error: ' + e, 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Generate ▸'; } + } + } + + // ─── Pose from wireframe video ─────────────────────────────────────────── + // Opens a modal that lists wireframe videos for the current group, + // lets the user scrub to a timestamp, then extracts keypoints from that + // frame and loads them into the draggable pose overlay. + async function lbPoseFromVideo() { + const st = window._poseState; + if (!st) { showToast('Open Pose first', 'info'); return; } + + // Gather wireframe videos from the current group's images. + const videos = (lbNames || []).filter(n => /\.(mp4|webm|mov)$/i.test(n)); + if (!videos.length) { + showToast('No videos in this group — generate a wireframe video first', 'info'); + return; + } + + // Build modal + const overlay = document.createElement('div'); + overlay.id = 'poseVideoModal'; + overlay.style.cssText = 'position:fixed;inset:0;z-index:900;background:rgba(0,0,0,.8);display:flex;align-items:center;justify-content:center;'; + overlay.innerHTML = ` +
+
+ Pick pose from wireframe video + +
+
+ + +
+
+ +
+
+ + + 0.0 s +
+
+ + +
+
`; + document.body.appendChild(overlay); + + const vidEl = document.getElementById('pvmVideo'); + const select = document.getElementById('pvmVideoSelect'); + const scrubber = document.getElementById('pvmScrubber'); + const timeLabel = document.getElementById('pvmTimeLabel'); + + function pvmSetVideo(filename) { + vidEl.src = `${API}/output/${encodeURIComponent(filename)}`; + vidEl.load(); + vidEl.addEventListener('loadedmetadata', () => { + const dur = vidEl.duration || 10; + scrubber.max = dur; + scrubber.step = Math.max(0.033, dur / 500); + scrubber.value = 0; + timeLabel.textContent = '0.0 s'; + }, { once: true }); + } + + scrubber.addEventListener('input', () => { + const t = parseFloat(scrubber.value); + vidEl.currentTime = t; + timeLabel.textContent = t.toFixed(2) + ' s'; + }); + + select.addEventListener('change', () => pvmSetVideo(select.value)); + pvmSetVideo(videos[0]); + + window.pvmLoadPose = async function() { + const video = select.value; + const time_s = parseFloat(scrubber.value); + const btn = document.getElementById('pvmLoadBtn'); + btn.disabled = true; + btn.textContent = 'Extracting…'; + try { + const r = await fetch(`${API}/pose/from-wireframe`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ video, time_s }), + }); + if (!r.ok) { showToast('Pose extract failed: ' + await r.text(), 'error'); btn.disabled = false; btn.textContent = 'Load pose'; return; } + const d = await r.json(); + // Rescale keypoints from video dimensions to current image dimensions + const scaleX = st.width / d.width; + const scaleY = st.height / d.height; + st.people[0] = d.keypoints.map(([x, y, c]) => [x * scaleX, y * scaleY, c]); + st._gestureName = null; // came from real video, not a named preset + _drawPose(); + document.getElementById('poseVideoModal').remove(); + showToast(`Pose loaded from ${video} @ ${time_s.toFixed(2)}s`, 'success'); + } catch (e) { + showToast('Pose extract error: ' + e, 'error'); + btn.disabled = false; btn.textContent = 'Load pose'; + } + }; + } + async function lbFindSimilarFaces() { if (!lbCurrentGid) return; try { @@ -3257,13 +3409,14 @@ const bar = document.createElement('div'); bar.id = 'cropBar'; - // Anchored at the TOP so the buttons never block cropping to the bottom edge. - bar.style.cssText = 'position:absolute;top:0;left:0;right:0;display:flex;gap:8px;padding:8px;background:rgba(0,0,0,0.75);z-index:101;justify-content:flex-end;align-items:center;'; + // pointer-events:none on the container so the canvas underneath captures drags + // from the very top of the image. Individual interactive controls opt back in. + bar.style.cssText = 'position:absolute;top:0;left:0;right:0;display:flex;gap:8px;padding:8px;background:rgba(0,0,0,0.75);z-index:101;justify-content:flex-end;align-items:center;pointer-events:none;'; bar.innerHTML = 'Drag to select crop area' - + '