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'
- + '