diff --git a/tour-comfy/car.html b/tour-comfy/car.html
index 4c71b3e..54656a1 100644
--- a/tour-comfy/car.html
+++ b/tour-comfy/car.html
@@ -1439,7 +1439,26 @@
text-align: center; transition: all 0.12s;
}
.sb-angle-btn:hover { border-color: #444; color: #ccc; }
+ .sb-angle-btn.done { border-color: #14532d; background: #052e16; color: #4ade80; position: relative; }
+ .sb-angle-btn.done::after { content: '✓'; position: absolute; top: 1px; right: 3px; font-size: 9px; color: #4ade80; opacity: 0.9; }
.sb-angle-btn.selected { border-color: #0e7490; background: #082f49; color: #7dd3fc; font-weight: 600; }
+
+ /* Generate panel: sticky footer (custom prompt + Generate) and selected-ref thumbnails */
+ .sb-gen-footer {
+ position: sticky; bottom: 0; margin: 8px -13px -12px; padding: 10px 13px;
+ background: #0d0d10; border-top: 1px solid #1f1f24;
+ }
+ .sb-gen-refs { display: flex; gap: 5px; margin-bottom: 8px; flex-wrap: wrap; }
+ .sb-gen-refs .sb-gen-ref { position: relative; width: 40px; height: 40px; flex-shrink: 0; }
+ .sb-gen-refs .sb-gen-ref img {
+ width: 40px; height: 40px; object-fit: cover; border-radius: 4px;
+ border: 1px solid #3d2008; background: #111;
+ }
+ .sb-gen-refs .sb-gen-ref-badge {
+ position: absolute; top: -4px; left: -4px; background: #f97316; color: #111;
+ font-size: 9px; font-weight: 700; border-radius: 50%; width: 15px; height: 15px;
+ display: flex; align-items: center; justify-content: center; line-height: 1;
+ }
#studioAngleBar {
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s;
@@ -1829,6 +1848,8 @@
+
+
Download
@@ -2175,6 +2196,7 @@
let _fsSelectedPoses = new Set();
let _sbSelectedAngles = new Set(); // selected camera angle names
let _sbRefIndices = []; // ordered filmstrip indices selected as multi-ref (#1, #2, #3)
+ let _sbRefMode = 'combine'; // with 2–3 refs: 'combine' → 1 multi-ref output; 'each' → one edit per ref (cross product)
let _sbWireframeRef = ''; // wireframe video name for pose guide
let _sbWireframeTime = 0.5; // normalized frame time 0–1
let _sbPoseEdits = {}; // poseName → edited text
@@ -2484,10 +2506,7 @@
const faceBook = document.getElementById('lbFaceBook');
const faceThumb = document.getElementById('lbFaceThumb');
if (faceBook && faceThumb && lbCurrentGid && lbIdx === 0 && !isVid) {
- const faceFname = lbCurrentGid.replace(/\//g, '_') + '_face.png';
- faceThumb.src = IMAGE_FOLDER + faceFname + '?t=' + Date.now();
- faceThumb.onerror = () => { faceBook.style.display = 'none'; };
- faceThumb.onload = () => { faceBook.style.display = ''; };
+ loadFaceThumb(lbCurrentGid);
} else if (faceBook) {
faceBook.style.display = 'none';
}
@@ -2641,11 +2660,13 @@
lbNames.splice(lbIdx, 1);
if (lbNames.length === 0) {
closeStudio();
- refreshNow();
+ // Add a small delay to allow static regeneration to complete before refresh
+ setTimeout(refreshNow, 500);
} else {
lbIdx = Math.min(lbIdx, lbNames.length - 1);
updateStudio();
- refreshNow();
+ // Add a small delay to allow static regeneration to complete before refresh
+ setTimeout(refreshNow, 500);
}
} else {
showToast(`Failed to delete`, 'error');
@@ -2674,11 +2695,13 @@
lbNames.splice(lbIdx, 1);
if (lbNames.length === 0) {
closeStudio();
- refreshNow();
+ // Add a small delay to allow static regeneration to complete before refresh
+ setTimeout(refreshNow, 500);
} else {
lbIdx = Math.min(lbIdx, lbNames.length - 1);
updateStudio();
- refreshNow();
+ // Add a small delay to allow static regeneration to complete before refresh
+ setTimeout(refreshNow, 500);
}
} else {
showToast('Archive failed', 'error');
@@ -2729,6 +2752,29 @@
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
}
+ async function lbRotate(degrees) {
+ const fname = lbNames[lbIdx];
+ if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to rotate', 'info'); return; }
+ const btns = [document.getElementById('lbRotateLeftBtn'), document.getElementById('lbRotateRightBtn')];
+ btns.forEach(b => { if (b) b.disabled = true; });
+ try {
+ const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/rotate`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ degrees }),
+ });
+ if (r.ok) {
+ showToast('Rotated', 'success');
+ lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
+ updateStudio();
+ refreshNow();
+ } else {
+ showToast('Rotate failed: ' + await r.text(), 'error');
+ }
+ } catch (e) { showToast('Rotate failed: ' + e, 'error'); }
+ btns.forEach(b => { if (b) b.disabled = false; });
+ }
+
function startManualCrop() {
const fname = lbNames[lbIdx];
if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to crop', 'info'); return; }
@@ -2744,8 +2790,11 @@
const bar = document.createElement('div');
bar.id = 'cropBar';
- bar.style.cssText = 'position:absolute;bottom: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;';
+ // 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;';
bar.innerHTML = 'Drag to select crop area'
+ + ''
+ ''
+ '';
viewer.appendChild(bar);
@@ -2820,19 +2869,31 @@
const p2 = toImg(Math.max(st.x1, st.x2), Math.max(st.y1, st.y2));
if (p2.x - p1.x < 2 || p2.y - p1.y < 2) { showToast('Crop area too small', 'info'); return; }
+ const asCopy = document.getElementById('cropAsCopy')?.checked ?? false;
const btn = document.getElementById('cropConfirmBtn');
if (btn) { btn.disabled = true; btn.textContent = 'Cropping…'; }
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/crop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y }),
+ body: JSON.stringify({ x1: p1.x, y1: p1.y, x2: p2.x, y2: p2.y, as_copy: asCopy }),
});
if (r.ok) {
- showToast('Cropped', 'success');
+ const d = await r.json().catch(() => ({}));
cancelManualCrop();
- lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
- updateStudio();
+ if (asCopy && d.new_filename) {
+ showToast('Cropped to copy', 'success');
+ // Insert the cropped copy right after the original in the studio strip.
+ lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + d.new_filename + '?t=' + Date.now());
+ lbNames.splice(lbIdx + 1, 0, d.new_filename);
+ lbIdx = lbIdx + 1;
+ updateStudio();
+ refreshNow();
+ } else {
+ showToast('Cropped', 'success');
+ lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now();
+ updateStudio();
+ }
} else {
showToast('Crop failed: ' + await r.text(), 'error');
if (btn) { btn.disabled = false; btn.textContent = 'Crop'; }
@@ -2901,7 +2962,8 @@
const r = await fetch(`${API}/groups/${gid}`, { method: 'DELETE' });
if (r.ok) {
showToast(`Group ${gid} deleted`, 'success');
- refreshNow();
+ // Add a small delay to allow static regeneration to complete before refresh
+ setTimeout(refreshNow, 500);
} else {
showToast(`Failed to delete group ${gid}`, 'error');
}
@@ -3078,7 +3140,7 @@
// Primary: pre-generated static JSON (fast, no DB round-trip per request)
try {
- const r = await fetch(`${API}/output/_data/images.json`, { signal: AbortSignal.timeout(4000) });
+ const r = await fetch(`${API}/output/_data/images.json?t=${Date.now()}`, { signal: AbortSignal.timeout(4000) });
if (r.ok) {
const data = await r.json();
let imgs = data.images || [];
@@ -3780,6 +3842,16 @@
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();
+ }
+ if (e.key === 'End') {
+ lbIdx = lbUrls.length - 1;
+ updateStudio();
+ e.preventDefault();
+ }
if (e.key === ' ') {
const vid = document.getElementById('lbVideo');
if (vid && vid.style.display !== 'none') {
@@ -3788,6 +3860,22 @@
}
}
if ((e.key === 'h' || e.key === 'H') && tag !== 'INPUT') { lbToggleHidden(); }
+ if (e.key === 'F' || e.key === 'f') {
+ lbSetPreferred();
+ e.preventDefault();
+ }
+ if (e.key === 'Delete') {
+ lbArchive();
+ e.preventDefault();
+ }
+ if (e.key === 'ArrowUp') {
+ lbMoveInGroup(-1);
+ e.preventDefault();
+ }
+ if (e.key === 'ArrowDown') {
+ lbMoveInGroup(1);
+ e.preventDefault();
+ }
});
});
@@ -3838,6 +3926,33 @@
document.getElementById('privacyOverlay').style.display = 'none';
}
+ // Load the face-book thumbnail for a group. Face extraction runs
+ // asynchronously after "set preferred", so we ask the API whether the
+ // crop exists (works over file://, unlike probing with an that
+ // logs a 404 during the race) and poll a few times while it's pending.
+ async function loadFaceThumb(gid, retries = 0) {
+ const faceBook = document.getElementById('lbFaceBook');
+ const faceThumb = document.getElementById('lbFaceThumb');
+ if (!faceBook || !faceThumb || !gid) return;
+ try {
+ const r = await fetch(`${API}/faces/${encodeURIComponent(gid)}`);
+ const d = await r.json();
+ // Ignore stale responses if the user navigated away.
+ if (lbCurrentGid !== gid || lbIdx !== 0) return;
+ if (d.exists) {
+ faceThumb.onerror = () => { faceBook.style.display = 'none'; };
+ faceThumb.src = IMAGE_FOLDER + d.filename + '?t=' + Date.now();
+ faceBook.style.display = '';
+ } else if (retries > 0) {
+ setTimeout(() => loadFaceThumb(gid, retries - 1), 1500);
+ } else {
+ faceBook.style.display = 'none';
+ }
+ } catch (e) {
+ faceBook.style.display = 'none';
+ }
+ }
+
async function setAsPreferred(filename, btn) {
try {
const r = await fetch(`/images/${encodeURIComponent(filename)}/set-preferred`, {method:'POST'});
@@ -3848,6 +3963,8 @@
fileSortOrders[filename] = 0;
// Re-open picker to reflect new order
showVariantPicker(filename);
+ // Poll for the freshly-extracted face crop (async on the server).
+ loadFaceThumb(gid, 8);
} catch(e) { console.error(e); }
}
@@ -3930,18 +4047,20 @@
const relAngles = CAMERA_ANGLES.filter(a => a.relative);
let html = '