diff --git a/tour-comfy/car.html b/tour-comfy/car.html index dfd0e64..9bdca7f 100644 --- a/tour-comfy/car.html +++ b/tour-comfy/car.html @@ -1960,28 +1960,13 @@
-

- - - - - - Studio Monitor AI -

0 images - - - 🗑️ Trash
-
- - Auto-refresh: 2m -
`; } - statusDot.classList.remove('updating'); + if (statusDot) statusDot.classList.remove('updating'); loadImages._inFlight = false; return; } @@ -10176,7 +10305,7 @@ } } - statusDot.classList.remove('updating'); + if (statusDot) statusDot.classList.remove('updating'); loadImages._inFlight = false; } @@ -10209,7 +10338,8 @@ function setIntervalTime(seconds) { REFRESH_INTERVAL = seconds; - document.getElementById('statusText').textContent = `Auto-refresh: ${seconds < 60 ? seconds + 's' : (seconds / 60) + 'm'}`; + const statusText = document.getElementById('statusText'); + if (statusText) statusText.textContent = `Auto-refresh: ${seconds < 60 ? seconds + 's' : (seconds / 60) + 'm'}`; clearInterval(autoRefreshTimer); autoRefreshTimer = setInterval(_timedRefresh, REFRESH_INTERVAL * 1000); @@ -10248,6 +10378,15 @@ let availablePoses = {}; async function loadPoses() { + try { + const r = await fetch(`${API}/output/_data/poses.json?t=${Date.now()}`, { signal: AbortSignal.timeout(3000) }); + if (r.ok) { + availablePoses = await r.json(); + renderPoseMenu(); + return; + } + } catch (e) {} + try { const r = await fetch(`${API}/poses`); if (r.ok) { @@ -10655,8 +10794,10 @@ if (_isStudioOpen()) { requestAnimationFrame(() => { updatePadPreview(); - const cb = document.getElementById('sbCheckerboard'); - if (cb && cb.checked) toggleCheckerboard(true); + const viewer = document.getElementById('studioViewer'); + if (viewer && viewer.classList.contains('show-checker')) { + toggleCheckerboard(true); + } }); } }); @@ -10667,8 +10808,10 @@ if (_isStudioOpen()) { requestAnimationFrame(() => { updatePadPreview(); - const cb = document.getElementById('sbCheckerboard'); - if (cb && cb.checked) toggleCheckerboard(true); + const viewer = document.getElementById('studioViewer'); + if (viewer && viewer.classList.contains('show-checker')) { + toggleCheckerboard(true); + } }); } }); @@ -12794,8 +12937,7 @@ preload.onload = () => { const img = document.getElementById('lbImg'); if (img) img.src = nobgUrl; - const cb = document.getElementById('sbCheckerboard'); - if (cb && !cb.checked) { cb.checked = true; toggleCheckerboard(true); } + toggleCheckerboard(true); }; preload.src = nobgUrl; } @@ -12814,8 +12956,7 @@ const statusEl = document.getElementById('sbSegStatus'); if (statusEl) statusEl.textContent = 'Original restored'; // Turn off checkerboard - const cb = document.getElementById('sbCheckerboard'); - if (cb && cb.checked) { cb.checked = false; toggleCheckerboard(false); } + toggleCheckerboard(false); showToast('Original image shown', 'success'); } @@ -12841,8 +12982,7 @@ preload.onload = () => { const img = document.getElementById('lbImg'); if (img) img.src = nobgUrl; - const cb = document.getElementById('sbCheckerboard'); - if (cb && !cb.checked) { cb.checked = true; toggleCheckerboard(true); } + toggleCheckerboard(true); }; preload.src = nobgUrl; } diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py index f02a2f6..d9b7fbe 100644 --- a/tour-comfy/edit_api.py +++ b/tour-comfy/edit_api.py @@ -17,6 +17,7 @@ import random import copy import threading import csv +import subprocess try: from . import database @@ -106,6 +107,7 @@ app.add_middleware( # --- Activity tracking for idle-background turntable generation --------------- _last_request_time: float = time.time() +_last_user_generation_time: float = time.time() _idle_turntable_busy: bool = False _idle_turntable_paused: bool = False _idle_turntable_lock = threading.Lock() @@ -291,7 +293,7 @@ def _idle_turntable_daemon(): if _idle_turntable_paused: continue - if time.time() - _last_request_time < IDLE_THRESHOLD: + if time.time() - _last_user_generation_time < IDLE_THRESHOLD: continue try: @@ -340,7 +342,7 @@ def _idle_turntable_daemon(): continue # all angles done but no video yet — build it below # Re-check idle right before the expensive generation - if time.time() - _last_request_time < IDLE_THRESHOLD or _idle_turntable_paused: + if time.time() - _last_user_generation_time < IDLE_THRESHOLD or _idle_turntable_paused: break print(f"[turntable-bg] {group_id}: rendering {deg:.0f}° " @@ -348,6 +350,7 @@ def _idle_turntable_daemon(): with _idle_turntable_lock: _idle_turntable_busy = True + _write_turntable_static() try: from orbit_qwen import yaw_prompt, _autocrop_alpha @@ -401,6 +404,7 @@ def _idle_turntable_daemon(): finally: with _idle_turntable_lock: _idle_turntable_busy = False + _write_turntable_static() break # one view per cycle; re-check idle on next loop @@ -427,6 +431,7 @@ def on_startup(): threading.Thread(target=_watch_frontend, daemon=True).start() threading.Thread(target=_idle_turntable_daemon, daemon=True).start() threading.Thread(target=_consistency_check_daemon, daemon=True).start() + threading.Thread(target=_privacy_monitor_daemon, daemon=True).start() # Mount wireframe static dir for browser video preview try: wf_dir = _load_wireframe_dir() @@ -499,7 +504,10 @@ def _comfy_queue(graph: dict, client_id: str) -> str: def _comfy_wait(prompt_id: str, deadline: float) -> dict: """Poll /history until the prompt finishes; return its outputs dict.""" + global _last_user_generation_time while time.time() < deadline: + if not _idle_turntable_busy: + _last_user_generation_time = time.time() r = requests.get(f"{COMFY}/history/{prompt_id}", timeout=30) if r.status_code == 200: hist = r.json() @@ -1135,6 +1143,9 @@ def _run_pipeline( scheduler: str = "beta", extra_images: list = None, # additional PIL images wired to image2, image3 ) -> bytes: + global _last_user_generation_time + if not _idle_turntable_busy: + _last_user_generation_time = time.time() area = max_area if max_area > 0 else MAX_AREA pil, w, h = _prep_image(pil, area) buf = io.BytesIO() @@ -1226,6 +1237,8 @@ def _run_pipeline( client_id = uuid.uuid4().hex prompt_id = _comfy_queue(graph, client_id) outputs = _comfy_wait(prompt_id, time.time() + GEN_TIMEOUT) + if not _idle_turntable_busy: + _last_user_generation_time = time.time() png_bytes = _comfy_fetch_image(outputs) if post_process == "sam2": @@ -1276,6 +1289,29 @@ def _move_to_trash(filepath: str): _static_write_lock = threading.Lock() _invalidate_timer: "threading.Timer | None" = None _invalidate_timer_lock = threading.Lock() +_privacy_locked = False +_privacy_lock = threading.Lock() + + +def _privacy_monitor_daemon(): + """Monitors GNOME screen lock via D-Bus gdbus monitor.""" + global _privacy_locked + print("[privacy] Starting monitor daemon...") + # Monitor ScreenSaver state changes + cmd = ["gdbus", "monitor", "--session", "--dest", "org.gnome.ScreenSaver", "--object-path", "/org/gnome/ScreenSaver"] + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + # Lines look like: /org/gnome/ScreenSaver: org.gnome.ScreenSaver.ActiveChanged (true,) + if "ActiveChanged" in line: + is_locked = "(true,)" in line + with _privacy_lock: + if _privacy_locked != is_locked: + _privacy_locked = is_locked + print(f"[privacy] System lock state change detected: {is_locked}") + _write_all_static() + except Exception as e: + print(f"[privacy] Monitor error (is gdbus installed?): {e}") def _write_json(path: str, data) -> None: @@ -1287,7 +1323,7 @@ def _write_json(path: str, data) -> None: def _write_all_static() -> None: - """Regenerate /_data/{images,names,groups,group-names,videos}.json. + """Regenerate /_data/{images,names,groups,group-names,videos,poses}.json. Called on startup and after every mutation so the browser can\ fetch pre-computed JSON instead of hitting the DB on each page load. @@ -1371,6 +1407,12 @@ def _write_all_static() -> None: _write_json(os.path.join(data_dir, "videos.json"), _get_grouped_wireframes(wireframe_dir)) + # poses.json — pose library from poses.md + _write_json(os.path.join(data_dir, "poses.json"), _load_poses()) + + # system_status.json — for privacy lock etc + _write_json(os.path.join(data_dir, "system_status.json"), {"locked": _privacy_locked}) + # config.json — current config snapshot try: with open(CONFIG_PATH, "r") as _f: @@ -1505,6 +1547,18 @@ def _write_turntable_static() -> None: # Sort: complete first, then by most views done turntables.sort(key=lambda t: (-int(t["completed"]), -t["n_done"])) _write_json(os.path.join(data_dir, "turntables.json"), {"turntables": turntables}) + + summary = tc.get_status_summary(output_dir) + n_complete = sum(1 for v in summary.values() if v["completed"]) + status_payload = { + "groups": summary, + "n_complete": n_complete, + "n_total": len(summary), + "is_generating": _idle_turntable_busy, + "is_paused": _idle_turntable_paused, + "idle_seconds": round(time.time() - _last_user_generation_time, 1), + } + _write_json(os.path.join(data_dir, "turntable_status.json"), status_payload) except Exception as e: print(f"[static] write_turntable error: {e}") @@ -1947,6 +2001,17 @@ def get_batch(job_id: str): @app.get("/images") def list_images(archived: bool = False): output_dir = _load_output_dir() + static_file = os.path.join(output_dir, "_data", "images.json") + if os.path.exists(static_file): + try: + with open(static_file, "r") as f: + data = json.load(f) + imgs = data.get("images", []) + if not archived: + imgs = [x for x in imgs if not x.get("archived")] + return {"images": imgs} + except Exception as static_err: + print(f"[static-get] Failed to load images.json: {static_err}") all_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg') + VIDEO_EXTENSIONS try: try: @@ -2070,6 +2135,14 @@ def _get_grouped_wireframes(wireframe_dir: str) -> dict: @app.get("/videos") def list_videos(): """Return available wireframe template videos and grouped wireframe assets.""" + output_dir = _load_output_dir() + static_file = os.path.join(output_dir, "_data", "videos.json") + if os.path.exists(static_file): + try: + with open(static_file, "r") as f: + return json.load(f) + except Exception as static_err: + print(f"[static-get] Failed to load videos.json: {static_err}") wireframe_dir = _load_wireframe_dir() return _get_grouped_wireframes(wireframe_dir) @@ -2410,6 +2483,14 @@ def tag_image(req: TagRequest): @app.get("/names") def get_names(): + output_dir = _load_output_dir() + static_file = os.path.join(output_dir, "_data", "names.json") + if os.path.exists(static_file): + try: + with open(static_file, "r") as f: + return json.load(f) + except Exception as static_err: + print(f"[static-get] Failed to load names.json: {static_err}") try: persons = database.list_persons() return {p[0]: p[1] for p in persons if p[1]} @@ -2433,6 +2514,14 @@ def set_name(filename: str, body: dict): @app.get("/groups") def get_groups(): + output_dir = _load_output_dir() + static_file = os.path.join(output_dir, "_data", "groups.json") + if os.path.exists(static_file): + try: + with open(static_file, "r") as f: + return json.load(f) + except Exception as static_err: + print(f"[static-get] Failed to load groups.json: {static_err}") try: persons = database.list_persons() return {p[0]: p[2] for p in persons if p[2]} @@ -2478,6 +2567,14 @@ def extract_from_group(req: ExtractRequest): @app.get("/group-names") def get_group_names(): + output_dir = _load_output_dir() + static_file = os.path.join(output_dir, "_data", "group-names.json") + if os.path.exists(static_file): + try: + with open(static_file, "r") as f: + return json.load(f) + except Exception as static_err: + print(f"[static-get] Failed to load group-names.json: {static_err}") try: return database.get_all_group_names() except Exception as e: @@ -2632,6 +2729,29 @@ def health(): raise HTTPException(503, f"ComfyUI unreachable at {COMFY}: {e}") +@app.get("/privacy/status") +def get_privacy_status(): + return {"locked": _privacy_locked} + + +@app.post("/privacy/lock") +def set_privacy_lock(): + global _privacy_locked + with _privacy_lock: + _privacy_locked = True + _write_all_static() + return {"status": "locked"} + + +@app.post("/privacy/unlock") +def set_privacy_unlock(): + global _privacy_locked + with _privacy_lock: + _privacy_locked = False + _write_all_static() + return {"status": "unlocked"} + + def _crop_to_bbox(pil_img: Image.Image, margin: int = 20, top_margin: int = 20, headroom: float = 0.05) -> Image.Image: if pil_img.mode != 'RGBA': return pil_img @@ -5356,7 +5476,7 @@ def get_turntable_status(): "n_total": len(summary), "is_generating": _idle_turntable_busy, "is_paused": _idle_turntable_paused, - "idle_seconds": round(time.time() - _last_request_time, 1), + "idle_seconds": round(time.time() - _last_user_generation_time, 1), } @@ -5383,6 +5503,7 @@ def pause_turntable(): """Pause idle background generation.""" global _idle_turntable_paused _idle_turntable_paused = True + _write_turntable_static() return {"paused": True} @@ -5391,6 +5512,7 @@ def resume_turntable(): """Resume idle background generation.""" global _idle_turntable_paused _idle_turntable_paused = False + _write_turntable_static() return {"paused": False} diff --git a/tour-comfy/poses.md b/tour-comfy/poses.md index 9ab7a0c..633ddcc 100644 --- a/tour-comfy/poses.md +++ b/tour-comfy/poses.md @@ -29,9 +29,14 @@ should be on the exposed genitals, capturing their natural form and contours wit # Scene replace (18) -naturally replace the single female from Image 1 with the teenage female from image 2. +naturally replace the single female from Image 1 with the teenage female from image 2. Keep characteristics of person in Image 2 +# Scene Steel replace (18) + +replace the female in Image 1 by the teenage female in Image 2. Keep the Identical person in image 2. photo realistic + + # Undress masterpiece, high quality,