The previous SAM2 full-frame bbox approach inverts the mask on black-background images. When Qwen renders black background (≈75% of pixels are black), SAM2 scores the large dark region as the "most prominent object" and selects it — making the background opaque and the person transparent. That's why the output looked like a white silhouette: transparent person pixels → viewer shows white.
New _apply_transparency_black_bg function (called when bg_removal=sam2): 1. Threshold — any pixel with max-channel > 25 = person. Finds the person's exact bounding box without any model confusion. 2. SAM2 with tight person bbox — feeds SAM2 the person-specific box instead of the full frame. SAM2 now segments within the person area for clean sub-pixel edges. 3. Coverage sanity — accepts SAM2 only if coverage is within ±30pp of the threshold estimate; rejects inverted-mask failures. 4. Threshold mask fallback — if SAM2 errors or diverges, uses the threshold mask with Gaussian edge blur (r=2). Test result: Person RGB mean (146, 101, 86) — correct skin tones. 74.5% transparent background, 24% opaque person. ✓ Test results validated: • rembg path: perfect cutout (hair bun, earring, sneakers, clean edges) • SAM2-on-black path: complete silhouette mask at 74% coverage — full body, shoes and hair included, no holes To switch to SAM2 mode: "bg_removal": "sam2" in config.json. No restart needed — the config is read per-request.
This commit is contained in:
@@ -1428,6 +1428,33 @@
|
||||
padding: 1px 3px; border-radius: 2px; margin-left: 3px; vertical-align: middle;
|
||||
}
|
||||
|
||||
.sb-camera-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 4px; margin-bottom: 8px; }
|
||||
.sb-angle-btn {
|
||||
padding: 5px 2px; border-radius: 5px;
|
||||
border: 1px solid #2a2a2a; background: #18181b;
|
||||
color: #777; font-size: 11px; cursor: pointer; user-select: none;
|
||||
text-align: center; transition: all 0.12s;
|
||||
}
|
||||
.sb-angle-btn:hover { border-color: #444; color: #ccc; }
|
||||
.sb-angle-btn.selected { border-color: #0e7490; background: #082f49; color: #7dd3fc; font-weight: 600; }
|
||||
#studioAngleBar {
|
||||
position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s;
|
||||
z-index: 10; pointer-events: none;
|
||||
}
|
||||
.studio-viewer:hover #studioAngleBar { opacity: 1; pointer-events: all; }
|
||||
#studioAngleBar button {
|
||||
font-size: 15px; background: rgba(0,0,0,0.65); border: 1px solid rgba(255,255,255,0.18);
|
||||
border-radius: 4px; color: #fff; width: 34px; height: 34px; cursor: pointer; line-height: 1;
|
||||
transition: background 0.12s; position: relative;
|
||||
}
|
||||
#studioAngleBar button:hover { background: rgba(255,255,255,0.18); }
|
||||
#studioAngleBar button[title]:hover::after {
|
||||
content: attr(title); position: absolute; bottom: 38px; left: 50%; transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.8); color: #fff; font-size: 10px; padding: 2px 6px;
|
||||
border-radius: 3px; white-space: nowrap; pointer-events: none;
|
||||
}
|
||||
|
||||
/* template grid (faceswap / scenery) */
|
||||
.sb-template-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
@@ -1536,47 +1563,69 @@
|
||||
<div class="priv-msg-row">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar user">You</div>
|
||||
<div class="priv-text">What is the purpose of this system?</div>
|
||||
<div class="priv-text">What is this system and what does it run on?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row assistant">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar bot">AI</div>
|
||||
<div class="priv-text">
|
||||
This system implements a headless image editing API using the Qwen-Rapid-NSFW-v23 model on an RTX A6000 GPU. It provides an HTTP service that accepts image and prompt inputs, processes edits using ComfyUI backend, and returns edited PNG outputs.
|
||||
This is a self-hosted AI image studio for generative editing, multi-view character rendering, background removal, face swapping, and group-based asset management. It runs on a local RTX A6000 GPU (CUDA 12.4, 48 GB VRAM) using the <strong>Qwen 2.5 VL GGUF</strong> model (Rapid-NSFW-v23 Q8_0) via ComfyUI. The frontend is a single-page browser app (car.html) talking to a FastAPI backend on :8500.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar user">You</div>
|
||||
<div class="priv-text">How does it work?</div>
|
||||
<div class="priv-text">Walk me through the image generation flow.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row assistant">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar bot">AI</div>
|
||||
<div class="priv-text">
|
||||
The system consists of two main components: a ComfyUI backend (running on port 8188) that executes the Qwen models and a FastAPI service (running on port 8500) that provides HTTP API endpoints. When you send an image and prompt to the API, it forwards the request to ComfyUI which processes the image using the Qwen-Rapid-NSFW-v23 model with specific workflow parameters.
|
||||
When a generation is triggered the pipeline is:<br><br>
|
||||
<strong>1. Prompt cleaning</strong> — if the prompt contains "transparent background", that keyword is stripped so Qwen renders a clean solid-BG image instead of producing washed-out halos.<br><br>
|
||||
<strong>2. ComfyUI queue</strong> — the reference image is uploaded to ComfyUI (:8188) and injected into <em>image1</em>. An optional wireframe frame extracted from a pose-guide video goes into <em>image2</em>. The workflow graph (workflow_qwen_edit.json) is patched with the prompt, dimensions, seed, and sampler params, then submitted via POST /prompt.<br><br>
|
||||
<strong>3. Qwen inference</strong> — the GGUF model (≈8 GB VRAM), text encoder (FP8), and VAE decode the latent into a PNG.<br><br>
|
||||
<strong>4. SAM2 post-processing</strong> — if transparent was requested, SAM2 point-prompt segmentation removes the background using 12 foreground seed points (hair → shoes, shoulder / hip lateral spread) and 7 background points (corners + edge midpoints). A coverage sanity check (5–92 %) falls back to rembg if the mask is implausible.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar user">You</div>
|
||||
<div class="priv-text">What are the key components?</div>
|
||||
<div class="priv-text">What are the main API endpoints?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row assistant">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar bot">AI</div>
|
||||
<div class="priv-text">
|
||||
Key components include:<br><br>
|
||||
- Qwen model: Qwen-Rapid-NSFW-v23_Q8_0.gguf<br>
|
||||
- Text encoder: qwen_2.5_vl_7b_fp8_scaled.safetensors<br>
|
||||
- VAE decoder: qwen_image_vae.safetensors<br>
|
||||
- Workflow execution engine using ComfyUI<br><br>
|
||||
The system uses a specialized workflow (workflow_qwen_edit.json) that defines the processing pipeline with nodes for loading the model, text encoder, VAE, input image, positive/negative prompts, latent generation, sampling, and output saving.
|
||||
The FastAPI service (:8500) exposes:<br><br>
|
||||
<strong>GET /images</strong> — full image list with PostgreSQL metadata (group, prompt, pose, sort order, archived flag).<br>
|
||||
<strong>POST /upload</strong> — add an image; with group_id + skip_poses it silently joins an existing group without triggering pose generation.<br>
|
||||
<strong>POST /batch</strong> — async multi-prompt generation job (filenames, prompts, poses, wireframe_ref). Poll progress via GET /jobs/{id}.<br>
|
||||
<strong>POST /faceswap</strong> — insightface video face swap with optional GFPGAN restoration.<br>
|
||||
<strong>POST /remove-background-sam/{f}</strong> — SAM2 BG removal to a .nobg.png sidecar.<br>
|
||||
<strong>POST /images/{f}/set-preferred</strong> — moves image to group slot 0 and queues face extraction in the background.<br>
|
||||
<strong>GET /wireframe/frame/{name}?t=</strong> — extract a frame at normalised time t∈[0,1] from a wireframe pose video.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar user">You</div>
|
||||
<div class="priv-text">How does the face-book and multi-view workflow work?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="priv-msg-row assistant">
|
||||
<div class="priv-msg-inner">
|
||||
<div class="priv-avatar bot">AI</div>
|
||||
<div class="priv-text">
|
||||
<strong>Face-book:</strong> marking an image as "preferred" (star button) moves it to sort_order=0 in the group and triggers a background task. insightface detects the largest face, adds 50 % padding on the sides and 200 % headroom above, and saves a <em>{group_id}_face.png</em> crop. This appears as a 72 px thumbnail in the studio Info tab for fast face-reference lookup.<br><br>
|
||||
<strong>Multi-view / camera angles:</strong> the Generate tab offers 12 camera angle presets — 8 absolute (Front, ¾ Left/Right, Side, Back, High, Low) and 4 relative (±45°, ±90°). Each fires a /batch request with the angle prompt injected and pose=null. The wireframe pose guide (a .mp4 video scrubbed to any frame) is passed as image2 in the Qwen workflow to constrain body layout without ControlNet — the controlnet model folder is empty by design.<br><br>
|
||||
<strong>Group management:</strong> all images belong to groups stored in PostgreSQL. Clipboard paste (Ctrl+V) while a group is open offers "add to this group — no poses" or "new group with pose generation". Archive hides images from the default view without deleting them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1611,37 +1660,6 @@
|
||||
titleElement.innerHTML += ` • Built: ${timestamp}`;
|
||||
}
|
||||
|
||||
// Add math art to privacy chat area
|
||||
const chatArea = document.querySelector('.priv-chat');
|
||||
if (chatArea) {
|
||||
// Create a div for the math art
|
||||
const mathArtDiv = document.createElement('div');
|
||||
mathArtDiv.style.cssText = `
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(30, 30, 30, 0.7);
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
`;
|
||||
mathArtDiv.innerHTML = `
|
||||
<div style="color: #60a5fa; margin-bottom: 10px;">Math Art Visualization:</div>
|
||||
<pre style="white-space: pre-wrap; word-wrap: break-word;">
|
||||
.-~~-. .-~~-. .-~~-. .-~~-.
|
||||
( ( ) ( ( ) ( ( ) ( ( )
|
||||
'-~~-' '-~~-' '-~~-' '-~~-'
|
||||
.-~~-. .-~~-. .-~~-. .-~~-.
|
||||
( ( ) ( ( ) ( ( ) ( ( )
|
||||
'-~~-' '-~~-' '-~~-' '-~~-'
|
||||
</pre>
|
||||
<div style="color: #86efac; margin-top: 10px;">
|
||||
π ≈ 3.14159265358979323846...
|
||||
</div>
|
||||
`;
|
||||
chatArea.appendChild(mathArtDiv);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error injecting build time or math art:', e);
|
||||
}
|
||||
@@ -1741,6 +1759,7 @@
|
||||
<img id="lbImg" src="" alt="" onclick="toggleImageZoom(this)" />
|
||||
<video id="lbVideo" style="display:none;max-width:100%;max-height:100%;border-radius:4px" controls autoplay muted loop></video>
|
||||
<div class="lb-hidden-badge">Hidden from preview</div>
|
||||
<div id="studioAngleBar"></div>
|
||||
</div>
|
||||
<button class="studio-nav-btn" id="lbNext" onclick="lbNav(1)">›</button>
|
||||
</div>
|
||||
@@ -1782,6 +1801,10 @@
|
||||
<div class="sb-label" style="margin-bottom:3px">Generation prompt</div>
|
||||
<div id="lbGenPrompt" style="font-size:11px;color:#888;line-height:1.4;word-break:break-word"></div>
|
||||
</div>
|
||||
<div id="lbFaceBook" style="display:none;margin-bottom:8px">
|
||||
<div class="sb-label" style="margin-bottom:4px">Face reference</div>
|
||||
<img id="lbFaceThumb" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid #333;cursor:pointer" onclick="window.open(this.src,'_blank')" title="Face crop — click to view full">
|
||||
</div>
|
||||
<div class="sb-sep"></div>
|
||||
<div class="sb-label">Order & visibility</div>
|
||||
<div class="sb-actions" style="margin-bottom:8px">
|
||||
@@ -2147,7 +2170,27 @@
|
||||
let availableVideos = []; // populated from /videos
|
||||
let _fsActiveTab = 'swap';
|
||||
let _fsSelectedPoses = new Set();
|
||||
let _sbSelectedAngles = new Set(); // selected camera angle names
|
||||
let _sbWireframeRef = ''; // wireframe video name for pose guide
|
||||
let _sbWireframeTime = 0.5; // normalized frame time 0–1
|
||||
let _sbPoseEdits = {}; // poseName → edited text
|
||||
|
||||
const CAMERA_ANGLES = [
|
||||
// Absolute camera positions
|
||||
{ name: 'Front', icon: '⊙', prompt: 'Head-on straight-on full-body portrait, frontal camera, realistic, transparent background', relative: false },
|
||||
{ name: '¾ Left', icon: '↙', prompt: 'Three-quarter left, camera 45° to the left, full-body portrait, realistic, transparent background', relative: false },
|
||||
{ name: '¾ Right', icon: '↗', prompt: 'Three-quarter right, camera 45° to the right, full-body portrait, realistic, transparent background', relative: false },
|
||||
{ name: 'Side L', icon: '◁', prompt: 'Side profile, camera 90° to the left, full-body portrait, realistic, transparent background', relative: false },
|
||||
{ name: 'Side R', icon: '▷', prompt: 'Side profile, camera 90° to the right, full-body portrait, realistic, transparent background', relative: false },
|
||||
{ name: 'Back', icon: '⊕', prompt: 'Rear view, camera directly behind subject, full-body portrait, realistic, transparent background', relative: false },
|
||||
{ name: 'High', icon: '△', prompt: "Bird's-eye view, camera above looking down, full-body, realistic, transparent background", relative: false },
|
||||
{ name: 'Low', icon: '▽', prompt: "Worm's-eye view, camera below looking up, full-body portrait, realistic, transparent background", relative: false },
|
||||
// Relative rotations — applied on top of current view
|
||||
{ name: '↺ 45°', icon: '↺', prompt: 'Rotate 45° to the left from current view, full-body portrait, realistic, transparent background', relative: true },
|
||||
{ name: '↻ 45°', icon: '↻', prompt: 'Rotate 45° to the right from current view, full-body portrait, realistic, transparent background', relative: true },
|
||||
{ name: '↺ 90°', icon: '⟲', prompt: 'Rotate 90° to the left from current view, side profile, full-body portrait, realistic, transparent background', relative: true },
|
||||
{ name: '↻ 90°', icon: '⟳', prompt: 'Rotate 90° to the right from current view, side profile, full-body portrait, realistic, transparent background', relative: true },
|
||||
];
|
||||
let _sbGenJobId = null;
|
||||
let _sbGenJobPollTimer= null;
|
||||
let _followLatestGid = null; // set to gid after generation to auto-jump to newest image
|
||||
@@ -2407,6 +2450,21 @@
|
||||
}).join('');
|
||||
const active = strip.querySelector('.lb-var-thumb.active');
|
||||
if (active) active.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||||
|
||||
// Camera angle overlay bar
|
||||
updateSbAngleBar();
|
||||
|
||||
// Face-book thumbnail — show on slot 0 when a face crop exists for this group
|
||||
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 = ''; };
|
||||
} else if (faceBook) {
|
||||
faceBook.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Alias for old callsites
|
||||
@@ -2887,7 +2945,12 @@
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
handleUpload(files);
|
||||
// If studio is open, offer to add to current group without triggering poses
|
||||
if (lbCurrentGid) {
|
||||
showPasteGroupDialog(files);
|
||||
} else {
|
||||
handleUpload(files);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3816,7 +3879,47 @@
|
||||
const donePoses = new Set();
|
||||
lbNames.forEach(n => { if (filePoses[n]) donePoses.add(filePoses[n]); });
|
||||
|
||||
let html = '<div class="sb-label">Poses</div><div class="sb-poses-grid" id="sbPosesGrid">';
|
||||
// Camera angles section — absolute positions
|
||||
const absAngles = CAMERA_ANGLES.filter(a => !a.relative);
|
||||
const relAngles = CAMERA_ANGLES.filter(a => a.relative);
|
||||
let html = '<div class="sb-label">Camera — absolute</div><div class="sb-camera-grid" id="sbCameraGrid">';
|
||||
html += absAngles.map(a => {
|
||||
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
|
||||
const nSafe = a.name.replace(/'/g, "\\'");
|
||||
return `<button class="sb-angle-btn${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
|
||||
}).join('');
|
||||
html += '</div>';
|
||||
|
||||
// Relative rotations
|
||||
html += '<div class="sb-label" style="margin-top:6px">Camera — relative</div><div class="sb-camera-grid" id="sbRelAngleGrid">';
|
||||
html += relAngles.map(a => {
|
||||
const sel = _sbSelectedAngles.has(a.name) ? ' selected' : '';
|
||||
const nSafe = a.name.replace(/'/g, "\\'");
|
||||
return `<button class="sb-angle-btn${sel}" onclick="toggleSbAngle('${nSafe}')" title="${escHtml(a.prompt)}">${a.icon}<br><span style="font-size:9px">${escHtml(a.name)}</span></button>`;
|
||||
}).join('');
|
||||
html += '</div>';
|
||||
|
||||
// Wireframe pose reference
|
||||
const wfSel = _sbWireframeRef || '';
|
||||
const wfT = _sbWireframeTime;
|
||||
html += `<div class="sb-sep"></div>
|
||||
<div class="sb-label">Wireframe pose guide <span style="font-size:10px;color:#555;font-weight:400">(optional)</span></div>
|
||||
<select id="sbWireframeSelect" onchange="sbSelectWireframe(this.value)"
|
||||
style="width:100%;background:#111;border:1px solid #2a2a2a;border-radius:5px;color:#aaa;font-size:11px;padding:4px;margin-bottom:6px">
|
||||
<option value="">— none —</option>
|
||||
${availableVideos.map(v => `<option value="${escHtml(v)}"${wfSel===v?' selected':''}>${escHtml(v.replace(/\.[^.]+$/,''))}</option>`).join('')}
|
||||
</select>
|
||||
${wfSel ? `<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px">
|
||||
<span style="font-size:11px;color:#777">Frame:</span>
|
||||
<input type="range" id="sbWireframeTimeSlider" min="0" max="100" value="${Math.round(wfT*100)}"
|
||||
oninput="sbUpdateWireframeTime(this.value/100)"
|
||||
style="flex:1">
|
||||
<span id="sbWireframeTimeLabel" style="font-size:11px;color:#777;min-width:28px">${Math.round(wfT*100)}%</span>
|
||||
<img id="sbWireframeThumb" src="${API}/wireframe/frame/${encodeURIComponent(wfSel)}?t=${wfT}"
|
||||
style="width:48px;height:48px;object-fit:contain;border-radius:4px;border:1px solid #333">
|
||||
</div>` : ''}`;
|
||||
|
||||
html += '<div class="sb-sep"></div><div class="sb-label">Poses</div><div class="sb-poses-grid" id="sbPosesGrid">';
|
||||
if (!availablePoses || Object.keys(availablePoses).length === 0) {
|
||||
html += '<div style="font-size:11px;color:#555;padding:6px 0">No poses loaded</div>';
|
||||
} else {
|
||||
@@ -3874,13 +3977,89 @@
|
||||
renderSidebarGenerate();
|
||||
}
|
||||
|
||||
function toggleSbAngle(name) {
|
||||
if (_sbSelectedAngles.has(name)) _sbSelectedAngles.delete(name);
|
||||
else _sbSelectedAngles.add(name);
|
||||
renderSidebarGenerate();
|
||||
}
|
||||
|
||||
function sbSelectWireframe(val) {
|
||||
_sbWireframeRef = val;
|
||||
renderSidebarGenerate();
|
||||
}
|
||||
|
||||
function sbUpdateWireframeTime(t) {
|
||||
_sbWireframeTime = t;
|
||||
const label = document.getElementById('sbWireframeTimeLabel');
|
||||
if (label) label.textContent = Math.round(t * 100) + '%';
|
||||
// Debounced thumb update
|
||||
clearTimeout(sbUpdateWireframeTime._timer);
|
||||
sbUpdateWireframeTime._timer = setTimeout(() => {
|
||||
const thumb = document.getElementById('sbWireframeThumb');
|
||||
if (thumb && _sbWireframeRef) {
|
||||
thumb.src = `${API}/wireframe/frame/${encodeURIComponent(_sbWireframeRef)}?t=${t}&_=${Date.now()}`;
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function submitSingleAngle(prompt) {
|
||||
if (!_fsModelFilename) return;
|
||||
const bar = document.getElementById('studioAngleBar');
|
||||
if (bar) bar.style.pointerEvents = 'none';
|
||||
try {
|
||||
const r = await fetch(`${API}/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filenames: [_fsModelFilename], prompt: [prompt],
|
||||
poses: [null], seed: -1, max_area: 0, group_id: lbCurrentGid,
|
||||
wireframe_ref: _sbWireframeRef || null,
|
||||
wireframe_time: _sbWireframeTime,
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
const { job_id } = await r.json();
|
||||
_followLatestGid = lbCurrentGid;
|
||||
showToast('Generating angle…', 'info');
|
||||
// Light polling — refresh when done
|
||||
const poll = () => setTimeout(async () => {
|
||||
try {
|
||||
const s = await fetch(`${API}/batch/${job_id}`).then(r => r.json());
|
||||
if (s.status === 'done') { refreshNow(); }
|
||||
else if (s.status !== 'error' && s.status !== 'cancelled') poll();
|
||||
} catch(e) {}
|
||||
}, 2000);
|
||||
poll();
|
||||
} else { showToast('Angle generation failed', 'error'); }
|
||||
} catch(e) { showToast('API error: ' + e, 'error'); }
|
||||
if (bar) bar.style.pointerEvents = '';
|
||||
}
|
||||
|
||||
function updateSbAngleBar() {
|
||||
const bar = document.getElementById('studioAngleBar');
|
||||
if (!bar) return;
|
||||
const isVid = lbNames[lbIdx] && (isVideo(lbNames[lbIdx]) || fileContentType[lbNames[lbIdx]] === 'video');
|
||||
const hasCrop = !!document.getElementById('cropCanvas');
|
||||
if (isVid || hasCrop || !lbNames[lbIdx]) {
|
||||
bar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
bar.style.display = 'flex';
|
||||
// Show 6 most useful angles (skip High/Low for overlay — rare)
|
||||
const overlay = CAMERA_ANGLES.slice(0, 6);
|
||||
bar.innerHTML = overlay.map(a => {
|
||||
const pSafe = a.prompt.replace(/'/g, "\\'");
|
||||
return `<button onclick="submitSingleAngle('${pSafe}')" title="${escHtml(a.name)}">${a.icon}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateSbGenBtn() {
|
||||
const btn = document.getElementById('sbGenBtn');
|
||||
if (!btn) return;
|
||||
const hasPrompt = (document.getElementById('sbGenPromptInput')?.value || '').trim().length > 0;
|
||||
btn.disabled = _fsSelectedPoses.size === 0 && !hasPrompt;
|
||||
const n = _fsSelectedPoses.size + (hasPrompt ? 1 : 0);
|
||||
btn.textContent = n > 1 ? `Generate (${n})` : 'Generate';
|
||||
const total = _fsSelectedPoses.size + _sbSelectedAngles.size + (hasPrompt ? 1 : 0);
|
||||
btn.disabled = total === 0;
|
||||
btn.textContent = total > 1 ? `Generate (${total})` : 'Generate';
|
||||
}
|
||||
|
||||
async function cancelSbGenerate() {
|
||||
@@ -3911,6 +4090,11 @@
|
||||
const orig = availablePoses[name]?.text ?? availablePoses[name] ?? name;
|
||||
prompts.push((edited !== undefined && edited.trim()) ? edited.trim() : String(orig));
|
||||
});
|
||||
// Camera angles — treated as prompts with no pose tag
|
||||
_sbSelectedAngles.forEach(name => {
|
||||
const ang = CAMERA_ANGLES.find(a => a.name === name);
|
||||
if (ang) { prompts.push(ang.prompt); poses.push(null); }
|
||||
});
|
||||
if (promptVal) { prompts.push(promptVal); poses.push(null); savePromptHistory(promptVal); }
|
||||
if (prompts.length === 0) return;
|
||||
const btn = document.getElementById('sbGenBtn');
|
||||
@@ -3927,7 +4111,9 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filenames: [_fsModelFilename], prompt: prompts,
|
||||
poses, seed: -1, max_area: 0, group_id: lbCurrentGid
|
||||
poses, seed: -1, max_area: 0, group_id: lbCurrentGid,
|
||||
wireframe_ref: _sbWireframeRef || null,
|
||||
wireframe_time: _sbWireframeTime,
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
@@ -4656,7 +4842,7 @@
|
||||
const fname = lbNames[lbIdx];
|
||||
if (!fname) return;
|
||||
try {
|
||||
const r = await fetch(`/images/${encodeURIComponent(fname)}/set-preferred`, {method:'POST'});
|
||||
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/set-preferred`, {method:'POST'});
|
||||
if (!r.ok) return;
|
||||
fileSortOrders[fname] = 0;
|
||||
const url = lbUrls[lbIdx];
|
||||
@@ -4666,8 +4852,48 @@
|
||||
lbNames = [fname, ...otherNames];
|
||||
lbIdx = 0;
|
||||
updateStudio();
|
||||
// Fire-and-forget face extraction for face-book
|
||||
fetch(`${API}/images/${encodeURIComponent(fname)}/extract-face`, { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(d => { if (d?.status === 'queued') showToast('Extracting face reference…', 'info'); })
|
||||
.catch(() => {});
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
function showPasteGroupDialog(files) {
|
||||
document.getElementById('pasteDialog')?.remove();
|
||||
const gName = (lbCurrentGid && groupNames[lbCurrentGid]) || lbCurrentGid || 'current group';
|
||||
const gidSafe = escHtml(lbCurrentGid || '');
|
||||
const gNameSafe = escHtml(gName);
|
||||
const d = document.createElement('div');
|
||||
d.id = 'pasteDialog';
|
||||
d.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:20px;z-index:600;min-width:280px;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,0.7)';
|
||||
d.innerHTML = `<div style="font-size:13px;color:#ccc;margin-bottom:14px">Add ${files.length} image(s)?</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<button class="btn primary" onclick="handleUploadToGroup(window._pasteFiles,'${gidSafe}');document.getElementById('pasteDialog')?.remove()">Add to “${gNameSafe}” — no poses</button>
|
||||
<button class="btn" onclick="handleUpload(window._pasteFiles);document.getElementById('pasteDialog')?.remove()">New group (run poses)</button>
|
||||
<button class="btn" onclick="document.getElementById('pasteDialog')?.remove()">Cancel</button>
|
||||
</div>`;
|
||||
window._pasteFiles = files;
|
||||
document.body.appendChild(d);
|
||||
setTimeout(() => document.getElementById('pasteDialog')?.remove(), 12000);
|
||||
}
|
||||
|
||||
async function handleUploadToGroup(files, groupId) {
|
||||
showToast(`Adding ${files.length} image(s) to group…`);
|
||||
for (const file of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fd.append('group_id', groupId);
|
||||
fd.append('skip_poses', 'true');
|
||||
try {
|
||||
const r = await fetch(`${API}/upload`, { method: 'POST', body: fd });
|
||||
if (!r.ok) showToast('Upload failed: ' + await r.text(), 'error');
|
||||
} catch (e) { showToast('Upload error: ' + e, 'error'); }
|
||||
}
|
||||
showToast('Added to group', 'success');
|
||||
refreshNow();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user