diff --git a/architecturev2.svg b/architecturev2.svg deleted file mode 100644 index 87aa518..0000000 --- a/architecturev2.svg +++ /dev/null @@ -1,235 +0,0 @@ - - - - - - - - - - - - - - - - - - Qwen Image Edit Rapid-AIO v23 System Architecture - Complete System Overview with All Components and Interactions - - - Input/Processing Pipeline - - - - Stage Directory - /mnt/zim/tour-comfy/stage - Input images - - - - Output Directory - /mnt/zim/tour-comfy/output - Processed images - - - - Failed Directory - /mnt/zim/tour-comfy/failed - Failed images - - - System Services - - - - System Target - comfyui.target - Service orchestration - - - Backend Components - - - - ComfyUI Backend - Qwen-Rapid-NSFW-v23 - GGUF Model (21.8GB) - ComfyUI Workflow Engine - - - - GFPGAN Face Restoration - gfpgan directory - Face swap functionality - - - - Prompt Management - prompt_pose_book.md - Base prompts & pose book - - - - Configuration - config.json - API Settings & Paths - - - API and Database - - - - FastAPI Service - edit_api.py - Image Processing API - - - - PostgreSQL Database - host: 192.168.1.160 - port: 5433, dbname: dv - - - Watcher Service - - - - Folder Watcher - watcher.py - car.html Generator - - - System Integration - - - - System Services - comfyui-backend.service - comfyui-api.service - comfyui-watcher.service - - - - - - Image Upload - - - - Processing Result - - - - Database Access - - - - Database Access - - - - car.html Generation - - - - Database Updates - - - - Service Orchestration - - - - Prompt Usage - - - - Face Restoration - - - - Start Backend - - - - Start API - - - - Start Watcher - - \ No newline at end of file diff --git a/backlog.md b/backlog.md index a5d5520..b873ad1 100644 --- a/backlog.md +++ b/backlog.md @@ -3,6 +3,7 @@ ## Shipped (recent) Studio / Generate UX: + - ✅ Crop reaches the bottom edge (toolbar moved to top); choose copy-vs-replace ("Save as copy") - ✅ Paste-to-group image shows immediately (no refresh needed) - ✅ Generated images store the prompt used (and pose name) in the DB @@ -16,6 +17,7 @@ Studio / Generate UX: - ✅ Rotate ±90° in the studio Pose tools (rtmlib / RTMPose, 2D): + - ✅ Pose skeleton preview overlay in studio - ✅ Draggable joints to refine/explore a pose - ✅ Find similar poses across the library (translation/scale/mirror-invariant; `poses_index.json`) @@ -24,6 +26,7 @@ Pose tools (rtmlib / RTMPose, 2D): - ✅ "Orbit all" button in Generate tab selects all 6 absolute camera angles at once Scenery: + - ✅ source_refs now stores all references: person image + video name + extra ref image - ✅ /scenery/library endpoint — all scenery grouped by source video - ✅ "Previous scenery" panel in Scenery tab — browse & reuse scenery from any group, grouped by video @@ -54,18 +57,36 @@ poses: hyper realistic, lighting, detailled skin etc.. see last generations for create disk 128gb 70b dolphin uncencored op server. rating based pose, thumbs up/down find good/bad poses easier - (pairs well with the new pose index — could weight similar-pose results by rating) +(pairs well with the new pose index — could weight similar-pose results by rating) when refresh page, we lose track of current jobs running. generating poses themself should use the adviced dimensions rather than the base image reference. introduce a like/dislike base system for images/group to determine the sort order (and more) + - would also dislike the pose creating scenery should keep both video-frame + ref images as references, we only see 1 image now. ✅ fixed gesture ideas for future (require ControlNet / OpenPose conditioning): + - animate limbs along keyframe curves (true motion) - 3D-aware rotation / "circling around" with depth estimation - camera-orbit batch with consistent lighting across views + +# backlog2 +- remove "clean-db" button from UI- studio. But perhaps check daily for inconsistencies and show identified issues in a "Archive/Trash" view perhaps seperate html? +- OUTPAINT feature/ crop/ pad call qwen +- the pad/crop i think should allow also percentage values, since we looking in scaled images. +- valid multi-input way to facilite the 2.5/3d orbit +- Refine a POSE, so we have the ref (source) image + pose prompt. use the tour GPU + api to refine the pose with user instruction. Then use the source image + updated prompt to generate a new image. +- introduce custom padding features - auto crop afterwards + +in car.html - studio view we have a action to pad the image we also have this feature in the "Generate" tab. +1) add a checkbox (or similar) to instruct the image-model to outpaint the padded area. Also implement the outpainting functionality in edit_api.py, we can instruct qwen-image. We can have the same functionality in the "Generate" tab when no prompt is given but a padding is given. +2) we have a bug that when pressing the ui-action "duplicate" and "pad" the browser needs to refresh before we see the result. Other actions like generating from pose do not hae this bug, fix the bug and boy scout adjecnt actions if they are missing the required technical follow up. Fix the bug for the features. +3) In the "Generate" tab, the CUSTOM prompt is shown twice, above POSES and statically always in view in the right bottom. Remove the statically CUSTOM prompt from the UI. +4) The Generate button is always visible in the "Generate" tab, however we cannot jump to it via keyboard. Add a keyboard shortcut to jump to the Generate button "G" or simialler. +Improve the UI/UX for the end-user experience. Where effort is low and impact is high. +Use own insight for further refinement of these improvements. diff --git a/tour-comfy/bad-poses.md b/tour-comfy/bad-poses.md index 898ee6f..fe90391 100644 --- a/tour-comfy/bad-poses.md +++ b/tour-comfy/bad-poses.md @@ -1,9 +1,10 @@ # The kneeler (beta): -A realistic artistic nude female. + +A realistic artistic nude female. The model kneels on all fours with hands planted firmly under the shoulders and knees directly under the hips. One arm and the opposite leg are lifted completely off the ground, creating a powerful diagonal stretch across the body. Hips are lowered toward the ground. The composition emphasizes the muscular tension in the lifted limbs and the continuous curved lines of the arched back and torso. Strength and dynamic energy radiate from the pose. Studio lighting, anatomically precise, no background, plain neutral backdrop, high detail, professional figure reference photography. - # The Kneeling (Dynamic): + kneeling on all fours, hands under shoulders, knees under hips. one arm and opposite leg slightly lifted off the ground, creating diagonal tension. hips lowered toward the ground, back forming a smooth curved arc. @@ -12,6 +13,7 @@ Looking directly into camera. Strong, dynamic. Perfect anatomy, realistic # The Sphinx(beta): + lying on stomach, propped up on forearms. elbows under shoulders, chest lifted, back arched deeply. legs extended straight behind, toes pointed. @@ -20,6 +22,7 @@ Looking down, not at camera. Regal, poised. Perfect anatomy, realistic # The Ascension (beta): + lying on back. arms extended overhead, hands clasped or reaching past the head. legs lifted straight up together, fully extended toward ceiling. @@ -28,8 +31,8 @@ vertical line from shoulders through heels. Looking directly into camera. Weightless, transcendent. Perfect anatomy, realistic - # The Sickle (beta): + lying on back, legs lifted and bent. knees drawing toward the chest, then extending and lowering toward the head. feet arched, toes pointing toward the ground behind the head. @@ -41,6 +44,7 @@ Perfect anatomy, realistic --- # The Hogtie: + lying on stomach, wrists bound behind the back. ankles bound together, drawn toward the bound wrists. body arched backward, chest and thighs lifted slightly off the ground. @@ -52,6 +56,7 @@ Perfect anatomy, realistic --- # The Spread Eagle (Restrained): + lying on back, arms extended straight out to the sides, wrists bound to implied anchors. legs spread wide, ankles bound to implied anchors. back flat on the ground, hips centered. @@ -59,19 +64,24 @@ head tilted back, throat exposed. Looking upward, not at camera. Vulnerable, displayed. Perfect anatomy, realistic +# Single Steel Bar — Behind Knees, Through Elbows, Prone Display -# Single Steel Bar — Behind Knees, Through Elbows, Prone Display -You are in black void, folded over a single solid steel bar. One polished steel rod passing behind your knees and through your bent elbows, holding your legs bent and your arms pinned behind your back simultaneously. -Your body draped face-down over this single bar, chest hanging, posterior elevated. Ankles cuffed together below the bar. -Wrists cuffed together above the bar. One bar, two cuffs. Your body folded into a tight prone package, dorsal plane fully presented. Floating examination light above. +You are in black void, folded over a single solid steel bar. One polished steel rod passing behind your knees and through your bent elbows, holding your legs bent and your arms pinned behind your back simultaneously. +Your body draped face-down over this single bar, chest hanging, posterior elevated. Ankles cuffed together below the bar. +Wrists cuffed together above the bar. One bar, two cuffs. Your body folded into a tight prone package, dorsal plane fully presented. Floating examination light above. Eyes looking sideways at camera, keeping your facial characteristics as reference photo. One bar, full posterior examination access. Realistic, anatomically precise. -# Single Steel Pillory — Standing Bent, Neck and Wrists -You are bent forward at the waist in black void, locked into a single solid steel pillory. One heavy polished steel board, floating horizontally. Three holes: your neck locked in the center, your wrists locked to either side. The board holds you at hip height, torso parallel to the void floor. Your ankles cuffed together by a solid steel bar. No stand, no support — the pillory floats and you are fixed within it. Posterior fully exposed behind you. Floating examination light. Eyes looking forward at camera from beneath the board, keeping your facial characteristics as reference photo. One board, total bent immobilization. Realistic, anatomically precise. +# Single Steel Pillory — Standing Bent, Neck and Wrists -# Single Steel Hook — Suspended by the Waist, Arms and Legs Free -You are suspended in black void from a single solid steel hook. One polished steel curve descending from the darkness above, its blunt tip passing under a steel band cinched around your waist. Your body hangs horizontally, face down, arms and legs dangling free but separated by gravity. Ankles weighted by small steel cuffs, pulling your legs slightly apart. Wrists weighted by matching cuffs, pulling your arms forward. No other support. You hang from one hook, one band. Body suspended in full extension, all surfaces accessible. Floating examination light beneath you. Eyes looking at camera from your suspended position, keeping your facial characteristics as reference photo. One hook, full circumferential examination access. Realistic, anatomically precise. +You are bent forward at the waist in black void, locked into a single solid steel pillory. One heavy polished steel board, floating horizontally. Three holes: your neck locked in the center, your wrists locked to either side. The board holds you at hip height, torso parallel to the void floor. Your ankles cuffed together by a solid steel bar. No stand, no support — the pillory floats and you are fixed within it. Posterior fully exposed behind you. Floating examination light. Eyes looking forward at camera from beneath the board, keeping your facial characteristics as reference photo. One +board, total bent immobilization. Realistic, anatomically precise. +# Single Steel Hook — Suspended by the Waist, Arms and Legs Free -# Single Steel Ring — Wrists and Ankles Behind, Kneeling Open -You are kneeling in black void. A single solid steel ring, polished and seamless, floating behind your back. Your wrists locked into the ring at the top. Your ankles locked into the ring at the bottom, legs spread wide by its diameter. No other material. One ring, four limbs converged behind you. Your body folded forward, chest toward the void floor, posterior elevated and fully exposed. A floating examination light illuminates your open dorsal plane. Full access for examination. Eyes looking back at camera over your shoulder, keeping your facial characteristics as reference photo. One ring, absolute exposure, minimal material. Realistic, anatomically precise. +You are suspended in black void from a single solid steel hook. One polished steel curve descending from the darkness above, its blunt tip passing under a steel band cinched around your waist. Your body hangs horizontally, face down, arms and legs dangling free but separated by gravity. Ankles weighted by small steel cuffs, pulling your legs slightly apart. Wrists weighted by matching cuffs, pulling your arms forward. No other support. You hang from one hook, one band. Body suspended in full extension, all surfaces accessible. Floating examination light beneath you. Eyes looking at camera from +your suspended position, keeping your facial characteristics as reference photo. One hook, full circumferential examination access. Realistic, anatomically precise. + +# Single Steel Ring — Wrists and Ankles Behind, Kneeling Open + +You are kneeling in black void. A single solid steel ring, polished and seamless, floating behind your back. Your wrists locked into the ring at the top. Your ankles locked into the ring at the bottom, legs spread wide by its diameter. No other material. One ring, four limbs converged behind you. Your body folded forward, chest toward the void floor, posterior elevated and fully exposed. A floating examination light illuminates your open dorsal plane. Full access for examination. Eyes looking back at camera over your shoulder, keeping your facial characteristics as reference photo. One ring, +absolute exposure, minimal material. Realistic, anatomically precise. diff --git a/tour-comfy/car.html b/tour-comfy/car.html index 64a6626..5bf3df3 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -1395,7 +1395,118 @@ .sb-btn.teal:hover { background: #0f766e; } .sb-btn.purple { background: #3b0764; border-color: #6d28d9; color: #c4b5fd; } .sb-btn.purple:hover { background: #6d28d9; } + .sb-btn.amber { background: #431407; border-color: #92400e; color: #fcd34d; } + .sb-btn.amber:hover { background: #78350f; } .sb-btn:disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; } + + /* Orbit preview modal */ + #orbitModal { + display: none; position: fixed; inset: 0; z-index: 9000; + background: rgba(0,0,0,0.85); align-items: center; justify-content: center; + } + #orbitModal.open { display: flex; } + #orbitModal .orbit-box { + background: #111; border: 1px solid #2a2a2a; border-radius: 10px; + padding: 20px; max-width: 600px; width: 90%; position: relative; + } + #orbitModal .orbit-title { + font-size: 13px; font-weight: 600; color: #ccc; margin-bottom: 12px; + } + #orbitModal video { + width: 100%; border-radius: 6px; background: #000; + } + #orbitModal .orbit-status { + font-size: 11px; color: #666; margin: 8px 0 0; + } + #orbitModal .orbit-status.ok { color: #4ade80; } + #orbitModal .orbit-status.err { color: #f87171; } + #orbitModal .orbit-close { + position: absolute; top: 10px; right: 12px; + background: none; border: none; color: #555; font-size: 18px; + cursor: pointer; line-height: 1; + } + #orbitModal .orbit-close:hover { color: #ccc; } + #orbitModal .orbit-actions { + display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; align-items: center; + } + .orbit-engine-toggle { display: flex; gap: 4px; margin-bottom: 10px; } + .orbit-engine-toggle button { + flex: 1; padding: 5px 10px; font-size: 11px; border-radius: 4px; + border: 1px solid #333; background: #1a1a1a; color: #777; cursor: pointer; + } + .orbit-engine-toggle button.active { + background: #2563eb; color: #fff; border-color: #2563eb; + } + .orbit-qwen-controls { display: none; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 6px; } + .orbit-qwen-controls.visible { display: flex; } + #turntableBgStatus { + font-size: 10px; color: #555; margin-top: 4px; + display: flex; align-items: center; gap: 4px; + } + #turntableBgStatus .tbs-dot { + width: 6px; height: 6px; border-radius: 50%; background: #555; flex-shrink: 0; + } + #turntableBgStatus .tbs-dot.active { background: #f59e0b; animation: pulse 1.2s ease-in-out infinite; } + #turntableBgStatus .tbs-dot.done { background: #4ade80; animation: none; } + @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } + .orbit-badge { + font-size: 9px; background: #f59e0b; color: #000; border-radius: 9px; + padding: 1px 5px; margin-left: 4px; font-weight: 700; vertical-align: middle; + } + /* Frame flipper player */ + .orbit-player { display: none; flex-direction: column; align-items: stretch; gap: 0; } + .orbit-player.visible { display: flex; } + /* Fixed-height image container — prevents layout jumps when frames differ in size */ + .orbit-img-wrap { + width: 100%; aspect-ratio: 3/4; position: relative; border-radius: 6px; overflow: hidden; + background: repeating-conic-gradient(#222 0% 25%, #2a2a2a 0% 50%) 0 0 / 16px 16px; + flex-shrink: 0; + } + .orbit-img-wrap img { + position: absolute; inset: 0; width: 100%; height: 100%; + object-fit: contain; display: block; + } + /* Controls bar is fixed height — never moves */ + .orbit-player-controls { + display: flex; gap: 8px; align-items: center; flex-wrap: nowrap; + justify-content: space-between; width: 100%; padding: 6px 0 0; + flex-shrink: 0; min-height: 32px; + } + .orbit-player-controls button { padding: 3px 10px; font-size: 11px; flex-shrink: 0; } + .orbit-frame-counter { font-size: 10px; color: #555; min-width: 48px; text-align: center; flex-shrink: 0; } + /* Orbit sidebar tab */ + #sbPanelOrbit { flex-direction: column; gap: 0; overflow-y: auto; } + .orbit-tab-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 6px 0; } + .orbit-tab-card { + background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 6px; + padding: 6px; cursor: pointer; transition: border-color .15s; + display: flex; flex-direction: column; gap: 4px; align-items: center; + } + .orbit-tab-card:hover { border-color: #f59e0b; } + /* Card thumbnails: fixed square with contain (shows whole body, no crop) */ + .orbit-tab-card .otc-img-wrap { + width: 100%; aspect-ratio: 1/1; position: relative; border-radius: 4px; overflow: hidden; + background: repeating-conic-gradient(#222 0% 25%, #2a2a2a 0% 50%) 0 0 / 10px 10px; + flex-shrink: 0; + } + .orbit-tab-card .otc-img-wrap img { + position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; + } + .orbit-tab-card .otc-name { font-size: 10px; color: #aaa; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width: 100%; } + .orbit-tab-card .otc-progress { font-size: 9px; color: #555; flex-shrink: 0; } + .orbit-tab-card.complete { border-color: #1a3a1a; } + .orbit-tab-card.complete:hover { border-color: #4ade80; } + /* Full viewer in sidebar tab */ + .orbit-full-player { display: none; flex-direction: column; gap: 6px; padding: 0 0 8px; flex-shrink: 0; } + .orbit-full-player.visible { display: flex; } + .orbit-full-img-wrap { + width: 100%; aspect-ratio: 3/4; position: relative; border-radius: 6px; overflow: hidden; + background: repeating-conic-gradient(#222 0% 25%, #2a2a2a 0% 50%) 0 0 / 16px 16px; + flex-shrink: 0; + } + .orbit-full-img-wrap img { + position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; + } .sb-status { font-size: 11px; color: #555; margin: 4px 0; } .sb-status.ok { color: #4ade80; } .sb-status.err { color: #f87171; } @@ -1849,6 +1960,7 @@ +
@@ -1892,10 +2004,12 @@ + + Download @@ -1907,12 +2021,114 @@
+
+ +
+
+ +
Orbit 3D preview
+ + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+ + +
+ + + +
+
+
0 selected @@ -2247,6 +2463,11 @@ let _sbWireframeRef = ''; // wireframe video name for pose guide let _sbWireframeTime = 0.5; // normalized frame time 0–1 let _sbPoseEdits = {}; // poseName → edited text + let _sbPadTop = 0; // canvas padding (px) applied before generation + let _sbPadRight = 0; + let _sbPadBottom = 0; + let _sbPadLeft = 0; + let _sbPadOutpaint = false; const CAMERA_ANGLES = [ // Absolute camera positions @@ -2674,6 +2895,62 @@ updateLightbox(); } + async function lbMoveToPosition(pos) { + const gid = lbCurrentGid; + const data = groupData.get(gid); + if (!data || data.names.length < 2) return; + + // Get current index and target position + const currentIndex = lbIdx; + let targetIndex; + + if (pos === 1) { + // Move to second position (index 1) + targetIndex = 1; + } else if (pos === -2) { + // Move to before last position (second to last) + targetIndex = data.names.length - 2; + } else { + return; // Invalid position + } + + // Make sure target index is valid + if (targetIndex < 0 || targetIndex >= data.names.length) return; + + // If already at target position, do nothing + if (currentIndex === targetIndex) return; + + // Create a copy of the names array to work with + const newNames = [...data.names]; + + // Remove the item from its current position + const [movedItem] = newNames.splice(currentIndex, 1); + + // Insert the item at the target position + newNames.splice(targetIndex, 0, movedItem); + + // Update the group data with new order + data.names = newNames; + + // Update sort orders locally + data.names.forEach((fn, i) => { fileSortOrders[fn] = i; }); + + // Persist to API + try { + await fetch(`${API}/groups/${encodeURIComponent(gid)}/order`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filenames: data.names }) + }); + } catch (e) { /* non-fatal */ } + + // Update the current index to reflect new position + lbIdx = targetIndex; + lbUrls = data.urls; + lbNames = data.names; + updateLightbox(); + } + async function lbToggleHidden() { const fname = lbNames[lbIdx]; const newHidden = !fileHidden[fname]; @@ -3524,6 +3801,106 @@ } } + function startManualPad() { + const fname = lbNames[lbIdx]; + if (!fname || !/\.(png|jpg|jpeg|webp)$/i.test(fname)) { showToast('Select an image to pad', 'info'); return; } + const viewer = document.getElementById('studioViewer'); + if (!viewer) return; + if (document.getElementById('padBar')) return; // already active + + const bar = document.createElement('div'); + bar.id = 'padBar'; + bar.style.cssText = 'position:absolute;top:0;left:0;right:0;display:flex;gap:8px;padding:8px;background:rgba(0,0,0,0.85);z-index:101;justify-content:flex-end;align-items:center;flex-wrap:wrap;'; + const numInput = (id, label) => + ``; + bar.innerHTML = + `Pad canvas (px):` + + numInput('padTop','↑') + + numInput('padRight','→') + + numInput('padBottom','↓') + + numInput('padLeft','←') + + `` + + `` + + `` + + `` + + ``; + viewer.appendChild(bar); + } + + function cancelManualPad() { + document.getElementById('padBar')?.remove(); + } + + async function confirmManualPad() { + const fname = lbNames[lbIdx]; + if (!fname) return; + const top = document.getElementById('padTop')?.value || '0'; + const right = document.getElementById('padRight')?.value || '0'; + const bottom = document.getElementById('padBottom')?.value || '0'; + const left = document.getElementById('padLeft')?.value || '0'; + + const isZero = (v) => !v || v === '0' || v === '0%' || v === '0px'; + if (isZero(top) && isZero(right) && isZero(bottom) && isZero(left)) { showToast('Enter at least one padding value', 'info'); return; } + const fill = document.getElementById('padFill')?.value || 'black'; + const asCopy = document.getElementById('padAsCopy')?.checked ?? true; + const outpaint = document.getElementById('padOutpaint')?.checked ?? false; + const btn = document.getElementById('padConfirmBtn'); + if (btn) { btn.disabled = true; btn.textContent = outpaint ? 'Outpainting…' : 'Padding…'; } + try { + const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/pad`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ top, right, bottom, left, fill, as_copy: asCopy, outpaint }), + }); + if (r.ok) { + const d = await r.json().catch(() => ({})); + cancelManualPad(); + if (asCopy && d.new_filename) { + showToast(outpaint ? 'Outpainted (copy)' : 'Padded (copy)', 'success'); + const newFn = d.new_filename; + // Clone metadata locally to avoid refresh blink + filePrompts[newFn] = filePrompts[fname]; + filePoses[newFn] = filePoses[fname]; + fileHasBg[newFn] = outpaint ? true : fileHasBg[fname]; + fileHasClothing[newFn] = fileHasClothing[fname]; + fileContentType[newFn] = 'image'; + fileSourceRefs[newFn] = [fname]; + + lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + newFn + '?t=' + Date.now()); + lbNames.splice(lbIdx + 1, 0, newFn); + lbIdx = lbIdx + 1; + + const group = groupData.get(lbCurrentGid); + if (group) { + group.urls.splice(lbIdx, 0, lbUrls[lbIdx]); + group.names.splice(lbIdx, 0, lbNames[lbIdx]); + group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + } + + updateStudio(); + _followLatestGid = lbCurrentGid; + setTimeout(refreshNow, 500); // give server time to write static JSON + } else { + showToast('Padded', 'success'); + lbUrls[lbIdx] = IMAGE_FOLDER + fname + '?t=' + Date.now(); + updateStudio(); + } + } else { + showToast('Pad failed: ' + await r.text(), 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Pad'; } + } + } catch (e) { + showToast('Pad error: ' + e, 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Pad'; } + } + } + async function lbDuplicate() { const fname = lbNames[lbIdx]; if (!fname) return; @@ -3535,12 +3912,31 @@ if (r.ok) { const d = await r.json(); showToast('Duplicated', 'success'); + const newFn = d.new_filename; + + // Clone metadata locally to avoid refresh blink + filePrompts[newFn] = filePrompts[fname]; + filePoses[newFn] = filePoses[fname]; + fileHasBg[newFn] = fileHasBg[fname]; + fileHasClothing[newFn] = fileHasClothing[fname]; + fileContentType[newFn] = fileContentType[fname] || 'image'; + fileSourceRefs[newFn] = [fname]; + // Insert the duplicate right after the current image 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); + lbUrls.splice(lbIdx + 1, 0, IMAGE_FOLDER + newFn + '?t=' + Date.now()); + lbNames.splice(lbIdx + 1, 0, newFn); lbIdx = lbIdx + 1; + + const group = groupData.get(lbCurrentGid); + if (group) { + group.urls.splice(lbIdx, 0, lbUrls[lbIdx]); + group.names.splice(lbIdx, 0, lbNames[lbIdx]); + group.visibleIdx = group.names.map((_, i) => i).filter(i => !fileHidden[group.names[i]]); + } + updateStudio(); - refreshNow(); + _followLatestGid = lbCurrentGid; + setTimeout(refreshNow, 500); } else { showToast('Duplicate failed', 'error'); } @@ -3559,6 +3955,7 @@ showToast('Background removed', 'success'); fileHasBg[fname] = false; _bustStudioImage(fname); // cache-busts viewer + calls updateStudio() + refreshNow(); } else { showToast(`Failed to remove background for ${fname}`, 'error'); } @@ -3614,6 +4011,460 @@ if (btn) { btn.disabled = false; btn.textContent = 'Make video'; } } + // --------------------------------------------------------------------------- + // Orbit 3D preview + // --------------------------------------------------------------------------- + let _orbitPollTimer = null; + let _orbitEngine = 'depth'; // 'depth' or 'qwen' + let _orbitFrames = []; // frame URLs for flipper + let _orbitFlipperIdx = 0; + let _orbitFlipperTimer = null; + let _orbitFlipperFps = 1; + let _orbitFlipperRunning = false; + let _orbitCurrentMp4 = null; // last downloaded MP4 rel path + + // ---- engine toggle ---- + function orbitSetEngine(engine) { + _orbitEngine = engine; + document.getElementById('orbitEngineDepth').classList.toggle('active', engine === 'depth'); + document.getElementById('orbitEngineQwen').classList.toggle('active', engine === 'qwen'); + document.getElementById('orbitDepthControls').style.display = engine === 'depth' ? 'flex' : 'none'; + document.getElementById('orbitQwenControls').classList.toggle('visible', engine === 'qwen'); + if (engine === 'qwen') _refreshQwenCacheHint(); + } + + async function _refreshQwenCacheHint() { + const fname = lbNames[lbIdx]; + const cacheEl = document.getElementById('orbitQwenCacheStatus'); + const cacheBtn = document.getElementById('orbitLoadCacheBtn'); + if (!fname) return; + cacheEl.style.display = 'none'; + cacheBtn.style.display = 'none'; + try { + const d = await (await fetch(`${API}/turntable/status`)).json(); + let found = null; + for (const [, info] of Object.entries(d.groups || {})) { + if (info.preferred_filename === fname || lbNames.includes(info.preferred_filename)) { + found = info; break; + } + } + if (!found) return; + cacheEl.style.display = ''; + if (found.completed) { + cacheEl.textContent = '✓ Pre-generated — instant load available'; + cacheEl.style.color = '#4ade80'; + cacheBtn.style.display = ''; + } else { + const pct = Math.round(found.n_done / found.n_total * 100); + cacheEl.textContent = `Background: ${found.n_done}/${found.n_total} views (${pct}%)`; + cacheEl.style.color = '#f59e0b'; + } + } catch(_) {} + } + + // ---- open modal in settings state ---- + function lbOrbit() { + const fname = lbNames[lbIdx]; + if (!fname || isVideo(fname)) { showToast('Select an image first', 'info'); return; } + // Always open in settings, never auto-play previous result + _orbitShowSettings(); + document.getElementById('orbitModal').classList.add('open'); + if (_orbitEngine === 'qwen') _refreshQwenCacheHint(); + } + + function _orbitShowSettings() { + _orbitFlipperStop(); + document.getElementById('orbitSettings').style.display = ''; + document.getElementById('orbitPlayer').style.display = 'none'; + } + + function orbitBackToSettings() { + _orbitShowSettings(); + if (_orbitEngine === 'qwen') _refreshQwenCacheHint(); + } + + // ---- Generate button ---- + async function orbitGenerate() { + const fname = lbNames[lbIdx]; + if (!fname) return; + + // Switch to player state showing "rendering" status + document.getElementById('orbitSettings').style.display = 'none'; + document.getElementById('orbitPlayer').style.display = ''; + const status = document.getElementById('orbitStatus'); + status.className = 'orbit-status'; + document.getElementById('orbitFlipperWrap').classList.remove('visible'); + document.getElementById('orbitVideo').style.display = 'none'; + document.getElementById('orbitDlFramesBtn').style.display = 'none'; + document.getElementById('orbitDlBtn').style.display = 'none'; + document.getElementById('orbitNobgHint').style.display = 'none'; + + let body; + if (_orbitEngine === 'qwen') { + const n_views = parseInt(document.getElementById('orbitNviewsSelect').value, 10); + const steps = parseInt(document.getElementById('orbitStepsSelect').value, 10); + body = { filename: fname, engine: 'qwen', n_views, steps }; + status.textContent = 'Generating Qwen turntable (~9 min for 24 views)…'; + } else { + const parallax = parseInt(document.getElementById('orbitParallaxSlider').value, 10) / 100; + const mode = document.getElementById('orbitModeSelect').value; + body = { filename: fname, engine: 'depth', parallax, mode, n_frames: 36, fps: 24 }; + status.textContent = 'Rendering orbit frames…'; + } + + let jobId; + try { + const r = await fetch(`${API}/orbit`, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify(body), + }); + if (!r.ok) { + status.textContent = 'Error: ' + await r.text(); + status.className = 'orbit-status err'; + return; + } + const d = await r.json(); + jobId = d.job_id; + if (d.cached) status.textContent = 'Loading cached turntable…'; + } catch(e) { + status.textContent = 'Fetch error: ' + e; + status.className = 'orbit-status err'; + return; + } + + _orbitPoll(jobId); + } + + // ---- Load cached instantly ---- + async function orbitLoadCache() { + const fname = lbNames[lbIdx]; + if (!fname) return; + document.getElementById('orbitSettings').style.display = 'none'; + document.getElementById('orbitPlayer').style.display = ''; + document.getElementById('orbitStatus').textContent = 'Loading cached turntable…'; + document.getElementById('orbitStatus').className = 'orbit-status'; + document.getElementById('orbitFlipperWrap').classList.remove('visible'); + document.getElementById('orbitNobgHint').style.display = 'none'; + + try { + const r = await fetch(`${API}/orbit`, { + method: 'POST', headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ filename: fname, engine: 'qwen' }), + }); + if (!r.ok) { + document.getElementById('orbitStatus').textContent = 'Error: ' + await r.text(); + document.getElementById('orbitStatus').className = 'orbit-status err'; + return; + } + const d = await r.json(); + _orbitPoll(d.job_id); + } catch(e) { + document.getElementById('orbitStatus').textContent = 'Fetch error: ' + e; + document.getElementById('orbitStatus').className = 'orbit-status err'; + } + } + + // ---- Poll job until done ---- + function _orbitPoll(jobId) { + if (_orbitPollTimer) clearInterval(_orbitPollTimer); + let dots = 0; + _orbitPollTimer = setInterval(async () => { + dots = (dots + 1) % 4; + try { + const jd = await (await fetch(`${API}/batch/${jobId}`)).json(); + const status = document.getElementById('orbitStatus'); + if (jd.status === 'running') { + const detail = jd.status_detail ? ` (${jd.status_detail})` : ''; + status.textContent = 'Rendering' + '.'.repeat(dots + 1) + detail; + } else if (jd.status === 'done') { + clearInterval(_orbitPollTimer); + _orbitShowResult(jd); + } else { + clearInterval(_orbitPollTimer); + status.textContent = 'Failed: ' + (jd.error || jd.status); + status.className = 'orbit-status err'; + } + } catch(_) {} + }, 1500); + } + + // ---- Display result ---- + function _orbitShowResult(jd) { + const status = document.getElementById('orbitStatus'); + const cached = jd.cached ? ' (cached)' : ''; + + if (jd.frames && jd.frames.length > 0) { + // Frame flipper — shows transparency + _orbitStartFlipper(jd.frames); + status.textContent = `Done${cached} — ${jd.frames.length} frames`; + status.className = 'orbit-status ok'; + document.getElementById('orbitDlFramesBtn').style.display = ''; + if (jd.video_filename) { + _orbitCurrentMp4 = jd.video_filename; + document.getElementById('orbitDlBtn').style.display = ''; + } + } else if (jd.video_filename) { + // Depth-card MP4 fallback + const mp4 = jd.video_filename; + const src = mp4.includes('/') ? `${API}/output/${mp4}` : IMAGE_FOLDER + mp4; + const vid = document.getElementById('orbitVideo'); + vid.src = src + '?t=' + Date.now(); + vid.style.display = ''; + vid.load(); vid.play().catch(()=>{}); + status.textContent = `Done${cached}`; + status.className = 'orbit-status ok'; + _orbitCurrentMp4 = mp4; + document.getElementById('orbitDlBtn').style.display = ''; + if (jd.has_alpha === false) + document.getElementById('orbitNobgHint').style.display = ''; + } + _pollTurntableBadge(); + } + + // ---- Frame flipper engine ---- + function _orbitStartFlipper(frameRelPaths) { + _orbitFlipperStop(); + _orbitFrames = frameRelPaths.map(p => `${API}/output/${p}`); + _orbitFlipperIdx = 0; + _orbitFlipperRunning = true; + + const wrap = document.getElementById('orbitFlipperWrap'); + wrap.classList.add('visible'); + document.getElementById('orbitVideo').style.display = 'none'; + document.getElementById('orbitFlipperPlayBtn').textContent = '⏸ Pause'; + + const img = document.getElementById('orbitFlipperImg'); + if (_orbitFrames.length > 0) img.src = _orbitFrames[0]; + + _orbitFlipperTimer = setInterval(() => { + _orbitFlipperIdx = (_orbitFlipperIdx + 1) % _orbitFrames.length; + document.getElementById('orbitFlipperImg').src = _orbitFrames[_orbitFlipperIdx]; + document.getElementById('orbitFrameCounter').textContent = + `${_orbitFlipperIdx + 1}/${_orbitFrames.length}`; + }, Math.round(1000 / _orbitFlipperFps)); + } + + function _orbitFlipperStop() { + if (_orbitFlipperTimer) { clearInterval(_orbitFlipperTimer); _orbitFlipperTimer = null; } + _orbitFlipperRunning = false; + } + + function orbitFlipperToggle() { + if (_orbitFlipperRunning) { + _orbitFlipperStop(); + document.getElementById('orbitFlipperPlayBtn').textContent = '▶ Play'; + } else { + _orbitStartFlipper(_orbitFrames.map(u => u.replace(`${API}/output/`, ''))); + document.getElementById('orbitFlipperPlayBtn').textContent = '⏸ Pause'; + } + } + + function _orbitSetFps(fps) { + _orbitFlipperFps = fps; + if (_orbitFlipperRunning && _orbitFrames.length > 0) { + // Restart with new interval + _orbitFlipperStop(); + _orbitStartFlipper(_orbitFrames.map(u => u.replace(`${API}/output/`, ''))); + } + } + + // ---- Download helpers ---- + function orbitDownload() { + if (!_orbitCurrentMp4) return; + const a = document.createElement('a'); + a.href = _orbitCurrentMp4.includes('/') + ? `${API}/output/${_orbitCurrentMp4}` + : IMAGE_FOLDER + _orbitCurrentMp4; + a.download = _orbitCurrentMp4.split('/').pop(); + a.click(); + } + + function orbitDownloadFrames() { + // Download all frames as individual files + if (!_orbitFrames.length) return; + _orbitFrames.forEach((url, i) => { + const a = document.createElement('a'); + a.href = url; + a.download = `orbit_frame_${String(i).padStart(3,'0')}.png`; + // Stagger downloads slightly to avoid browser throttle + setTimeout(() => a.click(), i * 80); + }); + } + + function closeOrbitModal() { + if (_orbitPollTimer) { clearInterval(_orbitPollTimer); _orbitPollTimer = null; } + _orbitFlipperStop(); + document.getElementById('orbitModal').classList.remove('open'); + const v = document.getElementById('orbitVideo'); + v.pause(); v.src = ''; + } + + // Close on backdrop click + document.getElementById('orbitModal').addEventListener('click', function(e) { + if (e.target === this) closeOrbitModal(); + }); + + // --- Background turntable progress badge + sidebar orbit tab ---------- + let _turntablesData = []; // cached from _data/turntables.json + + async function _pollTurntableBadge() { + try { + const r = await fetch(`${API}/turntable/status`); + if (!r.ok) return; + const d = await r.json(); + const badge = document.getElementById('orbitBadge'); + const tbStatus = document.getElementById('turntableBgStatus'); + const tbsDot = document.getElementById('tbsDot'); + const tbsText = document.getElementById('tbsText'); + const total = d.n_total || 0; + const done = d.n_complete || 0; + if (total === 0) { badge.style.display = 'none'; } + else if (done < total) { badge.textContent = `${done}/${total}`; badge.style.display = ''; } + else { badge.style.display = 'none'; } + + if (document.getElementById('orbitModal').classList.contains('open') && tbStatus) { + tbStatus.style.display = 'flex'; + if (d.is_generating) { + tbsDot.className = 'tbs-dot active'; + tbsText.textContent = `Generating — ${done}/${total} groups complete`; + } else if (done >= total && total > 0) { + tbsDot.className = 'tbs-dot done'; + tbsText.textContent = `All ${total} groups pre-generated`; + } else if (d.is_paused) { + tbsDot.className = 'tbs-dot'; + tbsText.textContent = 'Background generation paused'; + } else { + tbsDot.className = 'tbs-dot'; + tbsText.textContent = `Idle — ${done}/${total} complete (starts after 45s idle)`; + } + } + + // Refresh orbit tab badge on the tab button + const tabBtn = document.getElementById('sbTabOrbit'); + if (tabBtn && done > 0) { + tabBtn.textContent = `Orbit${done < total ? ' (' + done + '/' + total + ')' : ' ✓'}`; + } + } catch(_) {} + } + setInterval(_pollTurntableBadge, 15000); + _pollTurntableBadge(); + + // ---- Orbit sidebar tab ---- + let _orbitTabFlippers = {}; // group_id -> {timer, idx, frames, img} + + async function renderSidebarOrbit() { + const panel = document.getElementById('sbPanelOrbit'); + panel.innerHTML = '
Loading…
'; + try { + const r = await fetch(`${IMAGE_FOLDER}_data/turntables.json?t=` + Date.now()); + if (!r.ok) { panel.innerHTML = '
No turntables yet.
'; return; } + const { turntables } = await r.json(); + _turntablesData = turntables || []; + } catch(_) { + panel.innerHTML = '
No turntables yet.
'; + return; + } + + if (!_turntablesData.length) { + panel.innerHTML = '
No turntables generated yet.
Background generation starts when the app is idle for 45s.
'; + return; + } + + // Header with controls + let html = `
+ ${_turntablesData.length} groups + +
+
+
`; + + for (const t of _turntablesData) { + const label = t.group_name || t.group_id.slice(0, 8); + const thumb = t.frames.length > 0 ? `${API}/output/${t.frames[0]}` : ''; + const compClass = t.completed ? ' complete' : ''; + html += `
+
${label}
+
${label}
+
${t.completed ? '✓' : `${t.n_done}/${t.n_total}`}
+
`; + } + html += '
'; + panel.innerHTML = html; + + // Start mini-flippers for completed turntables + for (const t of _turntablesData) { + if (t.completed && t.frames.length > 1) { + _orbitStartTabFlipper(t.group_id, t.frames); + } + } + } + + function _orbitStartTabFlipper(groupId, frameRels) { + const imgEl = document.getElementById(`otcImg_${groupId}`); + if (!imgEl) return; + const urls = frameRels.map(f => `${API}/output/${f}`); + let idx = 0; + if (_orbitTabFlippers[groupId]) clearInterval(_orbitTabFlippers[groupId].timer); + const timer = setInterval(() => { + idx = (idx + 1) % urls.length; + const el = document.getElementById(`otcImg_${groupId}`); + if (el) el.src = urls[idx]; + }, 1000); // 1fps in thumbnails + _orbitTabFlippers[groupId] = { timer, idx, urls }; + } + + function _orbitTabOpen(groupId) { + const t = _turntablesData.find(x => x.group_id === groupId); + if (!t || !t.frames.length) return; + + const viewer = document.getElementById('orbitFullViewer'); + if (!viewer) return; + + // Stop any existing full-player + if (viewer._flipperTimer) { clearInterval(viewer._flipperTimer); viewer._flipperTimer = null; } + + const urls = t.frames.map(f => `${API}/output/${f}`); + let idx = 0; + const label = t.group_name || t.group_id.slice(0, 8); + + viewer.className = 'orbit-full-player visible'; + viewer.innerHTML = ` +
+ ${label} + +
+
+ +
+
+ + 1/${urls.length} +
`; + + let fps = 1; + function tick() { + idx = (idx + 1) % urls.length; + const el = document.getElementById('orbitFullImg'); + if (el) el.src = urls[idx]; + const ctr = document.getElementById('orbitFullCounter'); + if (ctr) ctr.textContent = `${idx+1}/${urls.length}`; + viewer._flipperTimer = setTimeout(tick, Math.round(1000 / fps)); + } + window._orbitFullSetFps = (v, label) => { fps = v; if(label) label.textContent = v+'fps'; }; + viewer._flipperTimer = setTimeout(tick, Math.round(1000 / fps)); + + // Scroll into view + viewer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + // Force the studio image (filmstrip + main viewer) to reload from disk. function _bustStudioImage(fname) { const t = Date.now(); @@ -4526,6 +5377,13 @@ if ((e.key === 'p' || e.key === 'P') && tag !== 'INPUT' && tag !== 'TEXTAREA') { togglePrivacyMode(); return; } + if ((e.key === 'g' || e.key === 'G') && tag !== 'INPUT' && tag !== 'TEXTAREA') { + if (_activeSidebarTab === 'generate') { + const btn = document.getElementById('sbGenBtn'); + if (btn && !btn.disabled) btn.click(); + e.preventDefault(); + } + } if (e.key === 'Escape') { if (document.getElementById('variantPicker').classList.contains('open')) { closeVariantPicker(); return; @@ -4584,6 +5442,18 @@ document._lbMoveTimer = setTimeout(() => lbMoveInGroup(d), 120); e.preventDefault(); } + // Key 2 moves image to second position in group + if (e.key === '2') { + clearTimeout(document._lbMoveTimer); + document._lbMoveTimer = setTimeout(() => lbMoveToPosition(1), 120); // Position 1 = second place + e.preventDefault(); + } + // Key 9 moves image to before the last position in group + if (e.key === '9') { + clearTimeout(document._lbMoveTimer); + document._lbMoveTimer = setTimeout(() => lbMoveToPosition(-2), 120); // Position -2 = second to last + e.preventDefault(); + } }); }); @@ -4730,6 +5600,7 @@ if (tab === 'faceswap') renderSidebarFaceswap(); if (tab === 'scenery') renderSidebarScenery(); if (tab === 'segment') renderSidebarSegment(); + if (tab === 'orbit') renderSidebarOrbit(); } function toggleImageZoom() { @@ -4743,6 +5614,11 @@ if (!panel) return; // Preserve live input values before re-render const prevPromptVal = document.getElementById('sbGenPromptInput')?.value ?? ''; + _sbPadTop = document.getElementById('sbPadTop')?.value ?? _sbPadTop; + _sbPadRight = document.getElementById('sbPadRight')?.value ?? _sbPadRight; + _sbPadBottom = document.getElementById('sbPadBottom')?.value ?? _sbPadBottom; + _sbPadLeft = document.getElementById('sbPadLeft')?.value ?? _sbPadLeft; + _sbPadOutpaint = document.getElementById('sbPadOutpaint')?.checked ?? _sbPadOutpaint; _fsSelectedPoses.forEach(name => { const el = document.getElementById('sbPoseEdit_' + CSS.escape(name)); if (el) _sbPoseEdits[name] = el.value; @@ -4798,6 +5674,19 @@ style="width:48px;height:48px;object-fit:contain;border-radius:4px;border:1px solid #333">
` : ''}`; + // Custom prompt field - positioned above poses and under wireframe + html += `
Custom prompt
+
+ + +
`; + html += '
Poses
'; const poseCount = availablePoses ? Object.keys(availablePoses).length : 0; if (poseCount > 8) { @@ -4886,16 +5775,17 @@ : 'One edit per selected reference.'}`; } } - html += `
Custom prompt
-
- - + html += `
+ Pad: + ${['Top','Right','Bottom','Left'].map(side => { + const val = side==='Top'?_sbPadTop:side==='Right'?_sbPadRight:side==='Bottom'?_sbPadBottom:_sbPadLeft; + return ``; + }).join('')} +