aa
This commit is contained in:
@@ -1895,6 +1895,7 @@
|
||||
<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="lbPoseBtn" onclick="lbTogglePose()" title="Preview body pose skeleton">Pose</button>
|
||||
<button class="sb-btn" id="lbMakeVideoBtn" onclick="lbMakeVideo()" title="Stitch group images (or Shift+Click selection) into a short MP4">Make video</button>
|
||||
<a class="sb-btn" id="lbDownloadBtn" download style="text-decoration:none">Download</a>
|
||||
<button class="sb-btn" id="lbArchiveBtn" onclick="lbArchive()" style="color:#f59e0b" title="Move to archive (recoverable)">Archive</button>
|
||||
<button class="sb-btn danger" id="lbDeleteBtn" onclick="lbDeleteArm()" title="Click once to arm, then again to confirm">Delete</button>
|
||||
@@ -2926,7 +2927,9 @@
|
||||
+ '<div style="position:relative;display:inline-block">'
|
||||
+ ' <button class="sb-btn" id="gestureMenuBtn" onclick="toggleGestureMenu(event)" title="Apply a gesture preset to the skeleton">Gesture ▾</button>'
|
||||
+ ' <div id="gestureMenuPanel" style="display:none;position:absolute;top:100%;left:0;z-index:200;background:#18181b;border:1px solid #2a2a2a;border-radius:6px;padding:4px;box-shadow:0 6px 20px rgba(0,0,0,.7);min-width:150px;margin-top:4px"></div>'
|
||||
+ '</div>';
|
||||
+ '</div>'
|
||||
+ '<button class="sb-btn" onclick="lbPoseFromVideo()" title="Pick a pose from a wireframe video frame">From video</button>'
|
||||
+ '<button class="sb-btn" id="poseGenBtn" onclick="lbGenerateWithPose()" title="Generate the person in this pose (skeleton → Qwen image2 guide)">Generate ▸</button>';
|
||||
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 = `
|
||||
<div style="background:#18181b;border:1px solid #2a2a2a;border-radius:10px;padding:20px;width:480px;max-width:95vw;box-shadow:0 8px 40px rgba(0,0,0,.8)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||||
<span style="font-size:14px;font-weight:600">Pick pose from wireframe video</span>
|
||||
<button class="sb-btn" onclick="document.getElementById('poseVideoModal').remove()">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="font-size:11px;color:#aaa;display:block;margin-bottom:4px">Video</label>
|
||||
<select id="pvmVideoSelect" style="width:100%;background:#0f0f0f;color:#e0e0e0;border:1px solid #2a2a2a;border-radius:5px;padding:5px 8px;font-size:12px">
|
||||
${videos.map(v => `<option value="${escHtml(v)}">${escHtml(v)}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<video id="pvmVideo" style="width:100%;border-radius:6px;background:#000;max-height:260px" preload="metadata"></video>
|
||||
</div>
|
||||
<div style="margin-bottom:14px">
|
||||
<label style="font-size:11px;color:#aaa;display:block;margin-bottom:4px">Scrub to pose frame</label>
|
||||
<input type="range" id="pvmScrubber" min="0" max="100" step="0.1" value="0"
|
||||
style="width:100%;accent-color:#a78bfa">
|
||||
<span id="pvmTimeLabel" style="font-size:11px;color:#888">0.0 s</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="sb-btn" onclick="document.getElementById('poseVideoModal').remove()">Cancel</button>
|
||||
<button class="sb-btn primary" id="pvmLoadBtn" onclick="pvmLoadPose()">Load pose</button>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = '<span style="flex:1;font-size:11px;color:#aaa">Drag to select crop area</span>'
|
||||
+ '<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#ccc;cursor:pointer" title="Keep the original and crop a copy instead">'
|
||||
+ '<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#ccc;cursor:pointer;pointer-events:auto" title="Keep the original and crop a copy instead">'
|
||||
+ '<input type="checkbox" id="cropAsCopy" checked style="cursor:pointer">Save as copy</label>'
|
||||
+ '<button class="sb-btn" onclick="cancelManualCrop()">Cancel</button>'
|
||||
+ '<button class="sb-btn primary" id="cropConfirmBtn" disabled onclick="confirmManualCrop()">Crop</button>';
|
||||
+ '<button class="sb-btn" style="pointer-events:auto" onclick="cancelManualCrop()">Cancel</button>'
|
||||
+ '<button class="sb-btn primary" style="pointer-events:auto" id="cropConfirmBtn" disabled onclick="confirmManualCrop()">Crop</button>';
|
||||
viewer.appendChild(bar);
|
||||
|
||||
const st = window._cropState = { canvas, viewer, img, fname, x1: 0, y1: 0, x2: 0, y2: 0, dragging: false };
|
||||
@@ -3435,6 +3588,32 @@
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
|
||||
async function lbMakeVideo() {
|
||||
// Use Shift+Click selected refs if any, otherwise all images in the current group.
|
||||
let filenames;
|
||||
if (_sbRefIndices.length >= 2) {
|
||||
filenames = _sbRefIndices.map(i => lbNames[i]).filter(n => n && !isVideo(n));
|
||||
if (filenames.length < 2) { showToast('Need at least 2 non-video images selected (Shift+Click filmstrip)', 'info'); return; }
|
||||
} else {
|
||||
filenames = lbNames.filter(n => n && !isVideo(n));
|
||||
if (filenames.length < 2) { showToast('Need at least 2 images in this group', 'info'); return; }
|
||||
}
|
||||
const btn = document.getElementById('lbMakeVideoBtn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Creating…'; }
|
||||
try {
|
||||
const r = await fetch(`${API}/generate-video`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filenames, fps: 8, loop: true }),
|
||||
});
|
||||
if (!r.ok) { showToast('Video failed: ' + await r.text(), 'error'); return; }
|
||||
const d = await r.json();
|
||||
showToast(`Video created: ${d.output}`, 'success');
|
||||
_followLatestGid = lbCurrentGid;
|
||||
refreshNow();
|
||||
} catch (e) { showToast('Video error: ' + e, 'error'); }
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Make video'; }
|
||||
}
|
||||
|
||||
// Force the studio image (filmstrip + main viewer) to reload from disk.
|
||||
function _bustStudioImage(fname) {
|
||||
const t = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user