• Implemented global offline mode for all HuggingFace-dependent modules to eliminate unauthenticated request warnings and prevent external connection attempts during startup and runtime.

   Changes
   • Global Offline Configuration: Added HF_HUB_OFFLINE=1 and TRANSFORMERS_OFFLINE=1 to the environment variables at the top of edit_api.py, embeddings.py, watcher.py, and pose_llm/pose_llm_api.py.
   • Explicit Local Files Only: Updated all huggingface_hub.hf_hub_download calls in edit_api.py to include local_files_only=True, ensuring they never attempt to reach the Hub if models are already cached.
   • LLM Service Optimization: Updated AutoTokenizer and AutoModelForCausalLM calls in pose_llm/pose_llm_api.py to use local_files_only=True.
   • Consistency: Ensured that shared modules like embeddings.py which uses open_clip are also locked to offline mode to prevent background downloads.
This commit is contained in:
mike
2026-06-29 12:27:10 +02:00
parent 3b9b9e829b
commit 150ef6dab0
10 changed files with 2078 additions and 119 deletions

View File

@@ -69,7 +69,10 @@ Scenery:
- samengestelde outpaint featurue lijkt niet goed te werken, but individual steps work perfect. - samengestelde outpaint featurue lijkt niet goed te werken, but individual steps work perfect.
- regenerate a single orbit - pose - regenerate a single orbit - pose
- when generating a known pose, we should know roughly the image ratio. Make a pose-table, when a pose is generated store the wireframe and meta data - when generating a known pose, we should know roughly the image ratio. Make a pose-table, when a pose is generated store the wireframe and meta data
- we can rate an image if its OVER21, manually now in the UI. this is fine, however we want to backfill this TAG for evey image - we can rate an image if its OVER21, manually now in the UI. this is fine, however we want to backfill this TAG for evey image
- backfill, people-count, 21+, IS_COMPLETE, RESTRAINT
- the wireframe / videos / sub-clips worden niet direct weergegeven
-
## refine ## refine
- when refresh page, we lose track of current jobs running. - when refresh page, we lose track of current jobs running.

117
ph_downloader.py Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Pornhub Video Downloader Side-Script
Uses yt-dlp under the hood for highly robust and fast downloads.
"""
import sys
import os
import subprocess
# Ensure yt-dlp and curl-cffi are installed
try:
import yt_dlp
import curl_cffi
except ImportError:
print("[-] yt-dlp or curl-cffi is not fully installed in this Python environment.")
print("[*] Attempting to install yt-dlp with curl-cffi automatically...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", "yt-dlp[default,curl-cffi]"])
import yt_dlp
import curl_cffi
print("[+] Successfully installed yt-dlp and curl-cffi!")
except Exception as e:
print(f"[!] Failed to install dependencies automatically: {e}")
print("[!] Please install manually with: pip install \"yt-dlp[default,curl-cffi]\"")
sys.exit(1)
import argparse
def download_video(url, output_dir=".", quality="best"):
print(f"[*] Initializing download for: {url}")
print(f"[*] Output directory: {os.path.abspath(output_dir)}")
if not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# Format selector configuration
# 'best' is the safest format because it downloads pre-merged streams and doesn't strictly require ffmpeg.
# 'bestvideo+bestaudio/best' will download separate video/audio streams and merge them if ffmpeg is present.
format_selector = 'best'
if quality != 'best':
if quality.endswith('p'):
height = quality[:-1]
format_selector = f'bestvideo[height<={height}]+bestaudio/best[height<={height}]'
else:
format_selector = quality
ydl_opts = {
'format': format_selector,
'outtmpl': os.path.join(output_dir, '%(title)s [%(id)s].%(ext)s'),
'noplaylist': True,
# Pornhub has age gates; standard user-agent and headers are handled by yt-dlp automatically,
# but let's add some basic robust options.
'ignoreerrors': False,
'logtostderr': False,
'quiet': False,
'no_warnings': False,
'nocheckcertificate': True,
}
# Setup browser impersonation to bypass Cloudflare/TLS fingerprinting blocks (e.g. HTTP 403 Forbidden)
try:
from yt_dlp.networking.impersonate import ImpersonateTarget
ydl_opts['impersonate'] = ImpersonateTarget.from_str('chrome')
except (ImportError, AttributeError):
ydl_opts['impersonate'] = 'chrome'
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
print("\n[+] Download completed successfully!")
print(f"[+] File saved to: {filename}")
return True
except Exception as e:
print(f"\n[!] Error downloading video: {e}")
# Try a safe fallback to 'best' format if we tried a complex format query
if format_selector != 'best':
print("[*] Retrying with fallback quality ('best')...")
ydl_opts['format'] = 'best'
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
filename = ydl.prepare_filename(info)
print("\n[+] Download completed successfully via fallback!")
print(f"[+] File saved to: {filename}")
return True
except Exception as fe:
print(f"[!] Fallback download also failed: {fe}")
return False
def main():
parser = argparse.ArgumentParser(description="Pornhub / General Video Downloader")
parser.add_argument("url", nargs="?", help="URL of the video to download")
parser.add_argument("-o", "--output", default="/mnt/zim/tour-comfy/wireframe/", help="Output directory path (default: current directory)")
parser.add_argument("-q", "--quality", default="720p", help="Video quality (e.g. best, 1080p, 720p, 480p) (default: 720p)")
args = parser.parse_args()
url = args.url
if not url:
# Interactive mode
try:
url = input("Enter video URL (e.g., Pornhub link): ").strip()
except (KeyboardInterrupt, EOFError):
print("\n[-] Cancelled.")
sys.exit(0)
if not url:
print("[!] No URL provided. Exiting.")
sys.exit(1)
success = download_video(url, args.output, args.quality)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"api_url": "http://127.0.0.1:8500/edit", "api_url": "http://127.0.0.1:8500/edit",
"base_prompts": [ "base_prompts": [
"masterpiece. high quality. hyper realistic. detailed, detailed skin", "masterpiece. high quality. hyper realistic. detailed, detailed skin",
"detailed female nude. realistic, detailed teenage female nude. realistic", "masterpiece. high quality. realistic. detailed. female nude. detailed teenage female nude. photo-realistic",
"masterpiece. high quality. realistic. detailed. female nude. photo-realistic", "masterpiece. high quality. realistic. detailed. female nude. photo-realistic",
"masterpiece. high quality. realistic. detailed. teenage female nude. photo-realistic", "masterpiece. high quality. realistic. detailed. teenage female nude. photo-realistic",
"Masterpeice, high quality, detailed, detailed skin, Head-on detailed full-nude-body three-quarter female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.", "Masterpeice, high quality, detailed, detailed skin, Head-on detailed full-nude-body three-quarter female portrait, photo realistic, black void background, Keep all characteristics and facial expressions of reference image.",

View File

@@ -2,6 +2,8 @@ import psycopg2
from psycopg2 import pool as _pgpool from psycopg2 import pool as _pgpool
import threading import threading
import json import json
import time
import random
DB_CONFIG = { DB_CONFIG = {
"host": "192.168.1.160", "host": "192.168.1.160",
@@ -115,6 +117,10 @@ def migrate_schema():
"ALTER TABLE person ADD COLUMN IF NOT EXISTS is_source BOOLEAN DEFAULT FALSE", "ALTER TABLE person ADD COLUMN IF NOT EXISTS is_source BOOLEAN DEFAULT FALSE",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose_description TEXT", "ALTER TABLE person ADD COLUMN IF NOT EXISTS pose_description TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose_skeleton TEXT", "ALTER TABLE person ADD COLUMN IF NOT EXISTS pose_skeleton TEXT",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS people_count INTEGER DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS anatomical_completeness BOOLEAN DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS facial_direction TEXT DEFAULT NULL",
"ALTER TABLE person ADD COLUMN IF NOT EXISTS objects JSONB DEFAULT NULL",
]: ]:
cur.execute(sql) cur.execute(sql)
conn.commit() conn.commit()
@@ -209,7 +215,8 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
has_background=None, source_refs=None, has_clothing=None, has_background=None, source_refs=None, has_clothing=None,
content_type=None, faceswap_source_video=None, archived=None, content_type=None, faceswap_source_video=None, archived=None,
face_embedding=None, is_source=None, face_embedding=None, is_source=None,
pose_description=None, pose_skeleton=None): pose_description=None, pose_skeleton=None,
people_count=None, anatomical_completeness=None, facial_direction=None, objects=None):
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor() cur = conn.cursor()
face_embedding_str = ("[" + ",".join(map(str, face_embedding)) + "]") if face_embedding is not None else None face_embedding_str = ("[" + ",".join(map(str, face_embedding)) + "]") if face_embedding is not None else None
@@ -219,8 +226,8 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
clip_description, prompt, pose, sort_order, group_name, hidden, clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing, has_background, source_refs, has_clothing,
content_type, faceswap_source_video, archived, face_embedding, is_source, content_type, faceswap_source_video, archived, face_embedding, is_source,
pose_description, pose_skeleton) pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction, objects)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (filename) DO UPDATE ON CONFLICT (filename) DO UPDATE
SET filepath = COALESCE(EXCLUDED.filepath, person.filepath), SET filepath = COALESCE(EXCLUDED.filepath, person.filepath),
name = COALESCE(EXCLUDED.name, person.name), name = COALESCE(EXCLUDED.name, person.name),
@@ -242,13 +249,18 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
face_embedding = COALESCE(EXCLUDED.face_embedding, person.face_embedding), face_embedding = COALESCE(EXCLUDED.face_embedding, person.face_embedding),
is_source = COALESCE(EXCLUDED.is_source, person.is_source), is_source = COALESCE(EXCLUDED.is_source, person.is_source),
pose_description = COALESCE(EXCLUDED.pose_description, person.pose_description), pose_description = COALESCE(EXCLUDED.pose_description, person.pose_description),
pose_skeleton = COALESCE(EXCLUDED.pose_skeleton, person.pose_skeleton); pose_skeleton = COALESCE(EXCLUDED.pose_skeleton, person.pose_skeleton),
people_count = COALESCE(EXCLUDED.people_count, person.people_count),
anatomical_completeness = COALESCE(EXCLUDED.anatomical_completeness, person.anatomical_completeness),
facial_direction = COALESCE(EXCLUDED.facial_direction, person.facial_direction),
objects = COALESCE(EXCLUDED.objects, person.objects);
""", (filename, filepath, name, group_id, """, (filename, filepath, name, group_id,
json.dumps(tags) if tags else None, json.dumps(tags) if tags else None,
embedding, clip_description, prompt, pose, sort_order, group_name, hidden, embedding, clip_description, prompt, pose, sort_order, group_name, hidden,
has_background, source_refs, has_clothing, has_background, source_refs, has_clothing,
content_type, faceswap_source_video, archived, face_embedding_str, is_source, content_type, faceswap_source_video, archived, face_embedding_str, is_source,
pose_description, pose_skeleton)) pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction,
json.dumps(objects) if objects else None))
conn.commit() conn.commit()
finally: finally:
cur.close() cur.close()
@@ -306,7 +318,8 @@ def get_person(filename):
cur.execute(""" cur.execute("""
SELECT name, group_id, tags, embedding, clip_description, filepath, SELECT name, group_id, tags, embedding, clip_description, filepath,
prompt, pose, sort_order, group_name, hidden, has_background, source_refs, prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
has_clothing, is_source, pose_description, pose_skeleton has_clothing, is_source, pose_description, pose_skeleton,
people_count, anatomical_completeness, facial_direction, objects
FROM person WHERE filename = %s FROM person WHERE filename = %s
""", (filename,)) """, (filename,))
return cur.fetchone() return cur.fetchone()
@@ -323,7 +336,7 @@ def list_persons(include_archived=False):
SELECT filename, name, group_id, clip_description, SELECT filename, name, group_id, clip_description,
prompt, pose, sort_order, group_name, hidden, has_background, source_refs, prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
has_clothing, content_type, faceswap_source_video, archived, is_source, tags, has_clothing, content_type, faceswap_source_video, archived, is_source, tags,
pose_description, pose_skeleton pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction, objects
FROM person FROM person
{where} {where}
""") """)
@@ -380,19 +393,43 @@ def get_group_files(group_id):
_put_db_connection(conn) _put_db_connection(conn)
def set_group_order(group_id, ordered_filenames): def set_group_order(group_id, ordered_filenames):
"""Assign sort_order 0,1,2,... to filenames in the given order.""" """Assign sort_order 0,1,2,... to filenames in the given order, resiliently."""
conn = get_db_connection() max_retries = 5
cur = conn.cursor() for attempt in range(max_retries):
try: conn = get_db_connection()
for idx, fname in enumerate(ordered_filenames): cur = conn.cursor()
cur.execute( try:
"UPDATE person SET sort_order = %s WHERE filename = %s", # Deterministic lock ordering to prevent deadlocks:
(idx, fname) # Fetch and lock all unique filenames alphabetically
) unique_filenames = sorted(list(set(ordered_filenames)))
conn.commit() if unique_filenames:
finally: cur.execute(
cur.close() "SELECT filename FROM person WHERE filename = ANY(%s) ORDER BY filename FOR UPDATE",
_put_db_connection(conn) (unique_filenames,)
)
for idx, fname in enumerate(ordered_filenames):
cur.execute(
"UPDATE person SET sort_order = %s WHERE filename = %s",
(idx, fname)
)
conn.commit()
break
except (psycopg2.OperationalError, psycopg2.extensions.TransactionRollbackError) as e:
pgcode = getattr(e, 'pgcode', None)
if pgcode in ('40P01', '40001') or "deadlock" in str(e).lower():
try:
conn.rollback()
except Exception:
pass
if attempt == max_retries - 1:
raise
sleep_time = (0.1 * (2 ** attempt)) + random.uniform(0.01, 0.1)
time.sleep(sleep_time)
else:
raise
finally:
cur.close()
_put_db_connection(conn)
def get_group_order(group_id): def get_group_order(group_id):
"""Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST.""" """Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST."""

View File

@@ -113,6 +113,7 @@ _last_user_generation_time: float = time.time()
_idle_turntable_busy: bool = False _idle_turntable_busy: bool = False
_idle_turntable_paused: bool = False _idle_turntable_paused: bool = False
_idle_turntable_lock = threading.Lock() _idle_turntable_lock = threading.Lock()
_failed_backfill_filenames = set()
IDLE_THRESHOLD = 45 # seconds of inactivity before background gen starts IDLE_THRESHOLD = 45 # seconds of inactivity before background gen starts
IDLE_CHECK_INTERVAL = 4 # polling interval (seconds) IDLE_CHECK_INTERVAL = 4 # polling interval (seconds)
@@ -439,6 +440,7 @@ def _idle_turntable_daemon():
sort_order=200 + angle_idx, sort_order=200 + angle_idx,
pose=f"Orbit {int(deg)}°" pose=f"Orbit {int(deg)}°"
) )
_update_cached_file_meta(vname, exists=True)
except Exception as db_err: except Exception as db_err:
print(f"[turntable-bg] DB registration error: {db_err}") print(f"[turntable-bg] DB registration error: {db_err}")
@@ -459,6 +461,36 @@ def _idle_turntable_daemon():
break # one view per cycle; re-check idle on next loop break # one view per cycle; re-check idle on next loop
# If no turntable views were generated, check if any legacy image needs metadata backfill
if not generated_one:
legacy_candidate = None
for row in persons:
fname = row[0]
content_type = row[12] if len(row) > 12 else None
people_count = row[19] if len(row) > 19 else None
if fname.startswith("_turntable/"):
continue
if content_type == 'video':
continue
if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
continue
fpath = os.path.join(output_dir, fname)
if not os.path.exists(fpath):
continue
if people_count is None and fname not in _failed_backfill_filenames:
legacy_candidate = fname
break
if legacy_candidate:
print(f"[metadata-bg] Idle backfill: processing {legacy_candidate}")
try:
res = _process_image_for_metadata(legacy_candidate)
if res is None:
_failed_backfill_filenames.add(legacy_candidate)
except Exception as ex:
print(f"[metadata-bg] Error processing legacy backfill for {legacy_candidate}: {ex}")
_failed_backfill_filenames.add(legacy_candidate)
def _finalize_turntable(output_dir: str, group_id: str, state: dict): def _finalize_turntable(output_dir: str, group_id: str, state: dict):
"""Mark state completed (without building the MP4).""" """Mark state completed (without building the MP4)."""
@@ -857,7 +889,7 @@ def _faceswap_worker(job_id: str, model_filename: str, video_name: str, enhance:
# 3. Write frame-swapped temp video # 3. Write frame-swapped temp video
ts = time.strftime("%Y%m%d_%H%M%S") ts = time.strftime("%Y%m%d_%H%M%S")
vid_stem = os.path.splitext(video_name)[0] vid_stem = os.path.splitext(video_name)[0]
dir_part = os.path.dirname(model_filename) dir_part = "" if model_filename.startswith("_turntable/") else os.path.dirname(model_filename)
basename = os.path.basename(model_filename) basename = os.path.basename(model_filename)
clean_basename = naming.get_base_name(basename) clean_basename = naming.get_base_name(basename)
prev_tag = f"_prev{int(scale*100)}" if scale < 1.0 else "" prev_tag = f"_prev{int(scale*100)}" if scale < 1.0 else ""
@@ -989,7 +1021,7 @@ def _faceswap_worker_ff(job_id: str, model_filename: str, video_name: str,
video_path = os.path.join(wireframe_dir, video_name) video_path = os.path.join(wireframe_dir, video_name)
ts = time.strftime('%Y%m%d_%H%M%S') ts = time.strftime('%Y%m%d_%H%M%S')
vid_stem = os.path.splitext(video_name)[0] vid_stem = os.path.splitext(video_name)[0]
dir_part = os.path.dirname(model_filename) dir_part = "" if model_filename.startswith("_turntable/") else os.path.dirname(model_filename)
basename = os.path.basename(model_filename) basename = os.path.basename(model_filename)
clean_basename = naming.get_base_name(basename) clean_basename = naming.get_base_name(basename)
scale = max(0.1, min(1.0, preview_scale)) scale = max(0.1, min(1.0, preview_scale))
@@ -1722,7 +1754,7 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
extra_imgs = [pose_guide_pil] if pose_guide_pil else None extra_imgs = [pose_guide_pil] if pose_guide_pil else None
png = _run_pipeline(pil, actual_prompt, seed, max_area, extra_images=extra_imgs) png = _run_pipeline(pil, actual_prompt, seed, max_area, extra_images=extra_imgs)
ts = time.strftime("%Y%m%d_%H%M%S") ts = time.strftime("%Y%m%d_%H%M%S")
dir_part = os.path.dirname(fname) dir_part = "" if fname.startswith("_turntable/") else os.path.dirname(fname)
basename = os.path.basename(fname) basename = os.path.basename(fname)
clean_basename = naming.get_base_name(basename) clean_basename = naming.get_base_name(basename)
new_basename = f"{ts}_{clean_basename}" new_basename = f"{ts}_{clean_basename}"
@@ -1751,6 +1783,7 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
has_background=has_bg, sort_order=next_order, has_background=has_bg, sort_order=next_order,
source_refs=json.dumps([fname]), source_refs=json.dumps([fname]),
) )
_update_cached_file_meta(out_name, exists=True)
except Exception as db_err: except Exception as db_err:
print(f"Database error in batch worker: {db_err}") print(f"Database error in batch worker: {db_err}")
@@ -1826,7 +1859,7 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
png = _run_pipeline(work_pil, actual_prompt, seed, max_area, extra_images=extra_pils) png = _run_pipeline(work_pil, actual_prompt, seed, max_area, extra_images=extra_pils)
ts = time.strftime("%Y%m%d_%H%M%S") ts = time.strftime("%Y%m%d_%H%M%S")
dir_part = os.path.dirname(primary_fname) dir_part = "" if primary_fname.startswith("_turntable/") else os.path.dirname(primary_fname)
basename = os.path.basename(primary_fname) basename = os.path.basename(primary_fname)
clean_basename = naming.get_base_name(basename) clean_basename = naming.get_base_name(basename)
new_basename = f"{ts}_mr_{clean_basename}" new_basename = f"{ts}_mr_{clean_basename}"
@@ -1853,6 +1886,7 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
group_id=output_gid, prompt=actual_prompt, pose=pose, group_id=output_gid, prompt=actual_prompt, pose=pose,
has_background=has_bg, sort_order=next_order, has_background=has_bg, sort_order=next_order,
source_refs=json.dumps([f for f, _ in pils])) source_refs=json.dumps([f for f, _ in pils]))
_update_cached_file_meta(out_name, exists=True)
except Exception as db_err: except Exception as db_err:
print(f"DB error in multi-ref: {db_err}") print(f"DB error in multi-ref: {db_err}")
@@ -1996,6 +2030,7 @@ def start_multi_ref(req: MultiRefRequest):
class RefineRequest(BaseModel): class RefineRequest(BaseModel):
prompt: str prompt: str
filename: str | None = None
@app.post("/refine-prompt") @app.post("/refine-prompt")
@@ -2004,13 +2039,60 @@ def refine_prompt(req: RefineRequest):
if not req.prompt: if not req.prompt:
raise HTTPException(400, "Prompt is required") raise HTTPException(400, "Prompt is required")
context_str = ""
if req.filename:
try:
person = database.get_person(req.filename)
if person:
# person columns: SELECT name, group_id, tags, embedding, clip_description, filepath,
# prompt, pose, sort_order, group_name, hidden, has_background, source_refs,
# has_clothing, is_source, pose_description, pose_skeleton,
# people_count, anatomical_completeness, facial_direction, objects
pose_desc = person[15]
people_count = person[17]
anatomical_completeness = person[18]
facial_direction = person[19]
objects_val = person[20]
context_parts = []
if pose_desc:
context_parts.append(f"Pose details: {pose_desc}")
if people_count is not None:
context_parts.append(f"Subject count: {people_count} person(s)")
if anatomical_completeness is not None:
context_parts.append(f"Anatomical completeness: {'complete/full body' if anatomical_completeness else 'partial/closeup'}")
if facial_direction:
context_parts.append(f"Gaze and facial direction: {facial_direction}")
if objects_val:
try:
if isinstance(objects_val, str):
objs = json.loads(objects_val)
else:
objs = objects_val
if objs:
tag_names = [o["tag"] for o in objs if isinstance(o, dict) and "tag" in o]
if tag_names:
context_parts.append(f"Detected elements/objects in scene: {', '.join(tag_names)}")
except Exception as parse_err:
print(f"[refine-prompt] failed to parse objects: {parse_err}")
if context_parts:
context_str = "\n".join(context_parts)
except Exception as db_err:
print(f"[refine-prompt] database error for {req.filename}: {db_err}")
user_content = f"Refine this pose: {req.prompt}"
if context_str:
user_content += f"\n\nUse the following image context details to ensure the refined prompt matches the reference characteristics closely:\n{context_str}"
# Use the same API as gen_poses.py # Use the same API as gen_poses.py
llm_api = "http://192.168.1.160:8001/v1/chat/completions" llm_api = "http://192.168.1.160:8001/v1/chat/completions"
payload = { payload = {
"model": "dphn/Dolphin3.0-Mistral-24B", "model": "dphn/Dolphin3.0-Mistral-24B",
"messages": [ "messages": [
{"role": "system", "content": REFINEMENT_SYSTEM}, {"role": "system", "content": REFINEMENT_SYSTEM},
{"role": "user", "content": f"Refine this pose: {req.prompt}"} {"role": "user", "content": user_content}
], ],
"temperature": 0.8, "temperature": 0.8,
"max_tokens": 1024 "max_tokens": 1024
@@ -2027,6 +2109,140 @@ def refine_prompt(req: RefineRequest):
raise HTTPException(500, f"LLM refinement failed: {str(e)}") raise HTTPException(500, f"LLM refinement failed: {str(e)}")
class DesignerGenerateRequest(BaseModel):
n: int = 3
context: str | None = None
filename: str | None = None
beta: bool = False
@app.post("/designer/generate")
def designer_generate(req: DesignerGenerateRequest):
"""Generate custom pose blocks using the external uncensored chat LLM, mirroring gen_poses.py."""
poses_dict = _load_poses()
existing_names = set(poses_dict.keys())
existing_lower = {k.lower() for k in existing_names}
# Select examples (prioritizing longer bodies)
items = [(name, entry.get("text") if isinstance(entry, dict) else entry) for name, entry in poses_dict.items()]
items = [(name, text) for name, text in items if text]
# Filter to at least 600 chars, or just sort by length descending
items_sorted = sorted(items, key=lambda x: len(x[1]), reverse=True)
examples = items_sorted[:3]
ex_str = "\n\n".join(f"# {name}\n{text}" for name, text in examples)
avoid = ", ".join(sorted(existing_names))
# Add active image context if filename is provided
img_context_str = ""
if req.filename:
try:
person = database.get_person(req.filename)
if person:
pose_desc = person[15]
people_count = person[17]
anatomical_completeness = person[18]
facial_direction = person[19]
objects_val = person[20]
parts = []
if pose_desc:
parts.append(f"Image pose details: {pose_desc}")
if people_count is not None:
parts.append(f"Subject count: {people_count} person(s)")
if facial_direction:
parts.append(f"Gaze/face direction: {facial_direction}")
if objects_val:
try:
if isinstance(objects_val, str):
objs = json.loads(objects_val)
else:
objs = objects_val
if objs:
tag_names = [o["tag"] for o in objs if isinstance(o, dict) and "tag" in o]
if tag_names:
parts.append(f"Detected scene elements: {', '.join(tag_names)}")
except Exception:
pass
if parts:
img_context_str = "\n".join(parts)
except Exception as e:
print(f"[designer] failed to fetch filename context: {e}")
# Build the prompt instructions
user_prompt = (
f"Here are example pose blocks in the exact required format:\n\n{ex_str}\n\n"
f"Write exactly {req.n} NEW pose blocks. "
)
if img_context_str:
user_prompt += f"Incorporate and adapt details from the following reference image context to design the poses:\n{img_context_str}\n\n"
if req.context:
user_prompt += f"Specific user guidelines/context to follow: {req.context}\n\n"
user_prompt += (
f"Requirements:\n"
f"- Each name must be completely unique and NOT appear in this list: {avoid}\n"
f"- Names must be inventive and specific — avoid short generic words\n"
f"- Explicit body positioning: specify limb placement, torso angle, hip/pelvis orientation, "
f"eye contact or gaze direction\n"
f"- Explicit NSFW content is expected and required\n"
f"Output only the {req.n} blocks, nothing else."
)
llm_api = "http://192.168.1.160:8001/v1/chat/completions"
payload = {
"model": "dphn/Dolphin3.0-Mistral-24B",
"messages": [
{"role": "system", "content": REFINEMENT_SYSTEM},
{"role": "user", "content": user_prompt}
],
"temperature": 0.9,
"max_tokens": 2400
}
try:
r = requests.post(llm_api, json=payload, timeout=120)
r.raise_for_status()
raw = r.json()["choices"][0]["message"]["content"]
except Exception as e:
print(f"[designer] LLM call failed: {e}")
raise HTTPException(500, f"LLM generation failed: {str(e)}")
# Parse generated poses (using helper similar to gen_poses.py's parse_poses)
generated = {}
cur = None
desc = []
for line in raw.splitlines():
line = line.strip()
if line.startswith("# "):
if cur:
generated[cur] = " ".join(desc).strip()
raw_header = line[2:].rstrip(":").strip()
cur = re.sub(r"\s*\(beta\)\s*", "", raw_header, flags=re.IGNORECASE).strip()
desc = []
elif line and cur:
desc.append(line)
if cur:
generated[cur] = " ".join(desc).strip()
# Filter out duplicates
new_poses = {}
for name, body in generated.items():
if not name or not body:
continue
if name.lower() in existing_lower or name.lower() in (k.lower() for k in new_poses):
print(f"[designer] skip duplicate: {name}")
continue
new_poses[name] = body
return {
"status": "success",
"poses": new_poses,
"raw": raw
}
@app.get("/poses") @app.get("/poses")
def get_poses(): def get_poses():
return _load_poses() return _load_poses()
@@ -2051,6 +2267,7 @@ def save_pose(req: PoseRequest):
poses.pop(req.old_name, None) poses.pop(req.old_name, None)
poses[name] = {"text": req.text.strip(), "beta": bool(req.beta)} poses[name] = {"text": req.text.strip(), "beta": bool(req.beta)}
_save_poses(poses) _save_poses(poses)
_invalidate_static()
return {"status": "success", "poses": poses} return {"status": "success", "poses": poses}
@@ -2062,6 +2279,7 @@ def delete_pose(name: str):
raise HTTPException(404, "Pose not found") raise HTTPException(404, "Pose not found")
poses.pop(name, None) poses.pop(name, None)
_save_poses(poses) _save_poses(poses)
_invalidate_static()
return {"status": "success", "poses": poses} return {"status": "success", "poses": poses}
@@ -2073,10 +2291,10 @@ def get_batch(job_id: str):
@app.get("/images") @app.get("/images")
def list_images(archived: bool = False): def list_images(archived: bool = False, bypass_static: bool = False):
output_dir = _load_output_dir() output_dir = _load_output_dir()
static_file = os.path.join(output_dir, "_data", "images.json") static_file = os.path.join(output_dir, "_data", "images.json")
if os.path.exists(static_file): if os.path.exists(static_file) and not bypass_static:
try: try:
with open(static_file, "r") as f: with open(static_file, "r") as f:
data = json.load(f) data = json.load(f)
@@ -2090,14 +2308,34 @@ def list_images(archived: bool = False):
try: try:
try: try:
persons = database.list_persons(include_archived=archived) persons = database.list_persons(include_archived=archived)
# list_persons cols: filename, name, group_id, clip_description,
# prompt, pose, sort_order, group_name, hidden, has_background,
# source_refs, has_clothing, content_type, faceswap_source_video, archived
db_images = [] db_images = []
for p in persons: for p in persons:
exists, mtime = _get_cached_file_meta(p[0], output_dir) exists, mtime = _get_cached_file_meta(p[0], output_dir)
if not exists: if not exists:
continue continue
tags_val = p[16]
tags_list = []
if tags_val:
if isinstance(tags_val, str):
try:
tags_list = json.loads(tags_val)
except Exception:
tags_list = []
elif isinstance(tags_val, list):
tags_list = tags_val
obj_val = p[22]
obj_list = []
if obj_val:
if isinstance(obj_val, str):
try:
obj_list = json.loads(obj_val)
except Exception:
obj_list = []
elif isinstance(obj_val, list):
obj_list = obj_val
db_images.append({ db_images.append({
"filename": p[0], "filename": p[0],
"name": p[1], "name": p[1],
@@ -2114,6 +2352,14 @@ def list_images(archived: bool = False):
"content_type": p[12] or "image", "content_type": p[12] or "image",
"faceswap_source_video":p[13], "faceswap_source_video":p[13],
"archived": bool(p[14]) if p[14] else False, "archived": bool(p[14]) if p[14] else False,
"is_source": bool(p[15]) if p[15] else False,
"tags": tags_list,
"pose_description": p[17],
"pose_skeleton": p[18],
"people_count": p[19],
"anatomical_completeness": p[20],
"facial_direction": p[21],
"objects": obj_list,
}) })
db_images.sort( db_images.sort(
key=lambda x: _get_cached_file_meta(x["filename"], output_dir)[1], key=lambda x: _get_cached_file_meta(x["filename"], output_dir)[1],
@@ -2547,6 +2793,8 @@ def tag_image(req: TagRequest):
clip_description=clip_desc, tags=tags, embedding=embedding, clip_description=clip_desc, tags=tags, embedding=embedding,
group_id=req.group_id, has_clothing=has_clothing, group_id=req.group_id, has_clothing=has_clothing,
) )
# Queue background deep metadata extraction
_metadata_executor.submit(_process_image_for_metadata, req.filename)
except Exception as db_err: except Exception as db_err:
print(f"Database error during tag: {db_err}") print(f"Database error during tag: {db_err}")
@@ -2986,15 +3234,13 @@ def upload_image(
shutil.copyfileobj(image.file, f) shutil.copyfileobj(image.file, f)
# Fast path: add to existing group without pose generation # Fast path: add to existing group without pose generation
if not group_id: if skip_poses:
group_id = naming.get_base_name(filename) target_group_id = group_id or naming.get_base_name(filename)
sort_order = database.get_next_sort_order(target_group_id)
if group_id and skip_poses: database.upsert_person(filename, filepath=file_path, group_id=target_group_id,
sort_order = database.get_next_sort_order(group_id)
database.upsert_person(filename, filepath=file_path, group_id=group_id,
sort_order=sort_order, is_source=True) sort_order=sort_order, is_source=True)
_invalidate_static() _invalidate_static()
return {"status": "added", "filename": filename, "group_id": group_id} return {"status": "added", "filename": filename, "group_id": target_group_id}
prompt_list = [p.strip() for p in prompts.split(",") if p.strip()] prompt_list = [p.strip() for p in prompts.split(",") if p.strip()]
@@ -3115,6 +3361,7 @@ def set_image_preferred(filename: str):
fpath = person[5] if (len(person) > 5 and person[5]) else os.path.join(_load_output_dir(), filename) fpath = person[5] if (len(person) > 5 and person[5]) else os.path.join(_load_output_dir(), filename)
if fpath and os.path.exists(fpath): if fpath and os.path.exists(fpath):
_face_executor.submit(_extract_face_bg, filename, fpath) _face_executor.submit(_extract_face_bg, filename, fpath)
_metadata_executor.submit(_process_image_for_metadata, filename)
return {"filename": filename, "group_id": group_id} return {"filename": filename, "group_id": group_id}
@@ -3646,6 +3893,7 @@ def remove_background(filename: str):
# Persist the state + refresh static data so the flag (and No-BG/Crop buttons) # Persist the state + refresh static data so the flag (and No-BG/Crop buttons)
# survive a page reload instead of reverting to has_background=True. # survive a page reload instead of reverting to has_background=True.
database.upsert_person(filename, has_background=False) database.upsert_person(filename, has_background=False)
_update_cached_file_meta(filename, exists=True)
_invalidate_static() _invalidate_static()
return {"status": "success", "filename": filename, "has_background": False} return {"status": "success", "filename": filename, "has_background": False}
@@ -3664,6 +3912,7 @@ def invert_alpha(filename: str):
arr[:, :, 3] = 255 - arr[:, :, 3] arr[:, :, 3] = 255 - arr[:, :, 3]
Image.fromarray(arr, "RGBA").save(path, format="PNG") Image.fromarray(arr, "RGBA").save(path, format="PNG")
database.upsert_person(filename, has_background=False) database.upsert_person(filename, has_background=False)
_update_cached_file_meta(filename, exists=True)
_invalidate_static() _invalidate_static()
return {"status": "success", "filename": filename} return {"status": "success", "filename": filename}
@@ -3680,6 +3929,8 @@ def remove_background_group(group_id: str, background_tasks: BackgroundTasks):
transparent_png = _apply_transparency(png_bytes) transparent_png = _apply_transparency(png_bytes)
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(transparent_png) f.write(transparent_png)
database.upsert_person(filename, has_background=False)
_update_cached_file_meta(filename, exists=True)
except Exception as e: except Exception as e:
print(f"Error removing background for {filename}: {e}") print(f"Error removing background for {filename}: {e}")
@@ -3775,7 +4026,7 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
extra_images=extra_images, extra_images=extra_images,
) )
ts = time.strftime("%Y%m%d_%H%M%S") ts = time.strftime("%Y%m%d_%H%M%S")
dir_part = os.path.dirname(model_filename) dir_part = "" if model_filename.startswith("_turntable/") else os.path.dirname(model_filename)
basename = os.path.basename(model_filename) basename = os.path.basename(model_filename)
clean_basename = naming.get_base_name(basename) clean_basename = naming.get_base_name(basename)
if not clean_basename.lower().endswith('.png'): if not clean_basename.lower().endswith('.png'):
@@ -3805,6 +4056,7 @@ def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
group_id=group_id, prompt=prompt, group_id=group_id, prompt=prompt,
sort_order=next_order, sort_order=next_order,
source_refs=json.dumps(refs)) source_refs=json.dumps(refs))
_update_cached_file_meta(out_name, exists=True)
except Exception as db_err: except Exception as db_err:
print(f"[scenery] DB error: {db_err}") print(f"[scenery] DB error: {db_err}")
jobs[job_id]["latest_output"] = out_name jobs[job_id]["latest_output"] = out_name
@@ -4261,7 +4513,7 @@ def remove_background_sam(filename: str):
pose_skeleton=person[16], pose_skeleton=person[16],
source_refs=json.dumps([filename]), # original is the reference source_refs=json.dumps([filename]), # original is the reference
) )
_update_cached_file_meta(nobg_filename, exists=True)
_invalidate_static() _invalidate_static()
used_sam2 = _sam2_predictor is not False and _sam2_predictor is not None used_sam2 = _sam2_predictor is not False and _sam2_predictor is not None
return { return {
@@ -4292,7 +4544,9 @@ def autocrop_image(filename: str):
cmin, cmax = np.where(cols)[0][[0, -1]] cmin, cmax = np.where(cols)[0][[0, -1]]
cropped = img.crop((cmin, rmin, cmax + 1, rmax + 1)) cropped = img.crop((cmin, rmin, cmax + 1, rmax + 1))
cropped.save(path, format="PNG") cropped.save(path, format="PNG")
_update_cached_file_meta(filename, exists=True)
_invalidate_static() _invalidate_static()
_metadata_executor.submit(_process_image_for_metadata, filename)
return {"status": "success", "filename": filename, "box": [int(cmin), int(rmin), int(cmax+1), int(rmax+1)]} return {"status": "success", "filename": filename, "box": [int(cmin), int(rmin), int(cmax+1), int(rmax+1)]}
@@ -4320,8 +4574,12 @@ def manual_crop_image(filename: str, req: CropRequest):
if req.as_copy: if req.as_copy:
# Mirror duplicate_image: copy file + register a DB row that points back to the original. # Mirror duplicate_image: copy file + register a DB row that points back to the original.
from datetime import datetime as _dt from datetime import datetime as _dt
output_dir = os.path.dirname(src_path) if filename.startswith("_turntable/"):
dir_part = os.path.dirname(filename) dir_part = ""
output_dir = _load_output_dir()
else:
output_dir = os.path.dirname(src_path)
dir_part = os.path.dirname(filename)
basename = os.path.basename(filename) basename = os.path.basename(filename)
stem, ext = os.path.splitext(basename) stem, ext = os.path.splitext(basename)
if not ext: if not ext:
@@ -4375,7 +4633,9 @@ def manual_crop_image(filename: str, req: CropRequest):
cropped = img.crop((x1, y1, x2, y2)) cropped = img.crop((x1, y1, x2, y2))
fmt = "PNG" if path.lower().endswith(".png") else "JPEG" fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
cropped.save(path, format=fmt) cropped.save(path, format=fmt)
_update_cached_file_meta(new_filename, exists=True)
_invalidate_static() _invalidate_static()
_metadata_executor.submit(_process_image_for_metadata, new_filename)
return {"status": "success", "filename": filename, "new_filename": new_filename, return {"status": "success", "filename": filename, "new_filename": new_filename,
"new_url": f"/output/{new_filename}", "as_copy": req.as_copy, "new_url": f"/output/{new_filename}", "as_copy": req.as_copy,
"box": [x1, y1, x2, y2]} "box": [x1, y1, x2, y2]}
@@ -4402,11 +4662,17 @@ def _apply_manual_pad(pil: Image.Image, top, right, bottom, left,
def resolve(val, total): def resolve(val, total):
if not val: if not val:
return 0 return 0
if isinstance(val, str) and "%" in val: if isinstance(val, str):
try: if "%" in val:
return int(float(val.replace("%", "")) * total / 100) try:
except: return int(float(val.replace("%", "")) * total / 100)
return 0 except:
return 0
if "px" in val:
try:
return int(float(val.replace("px", "")))
except:
return 0
try: try:
f = float(val) f = float(val)
if 0 < f < 1.0: if 0 < f < 1.0:
@@ -4451,8 +4717,12 @@ def pad_image(filename: str, req: PadRequest):
if req.as_copy: if req.as_copy:
from datetime import datetime as _dt from datetime import datetime as _dt
output_dir = os.path.dirname(src_path) if filename.startswith("_turntable/"):
dir_part = os.path.dirname(filename) dir_part = ""
output_dir = _load_output_dir()
else:
output_dir = os.path.dirname(src_path)
dir_part = os.path.dirname(filename)
basename = os.path.basename(filename) basename = os.path.basename(filename)
stem, ext = os.path.splitext(basename) stem, ext = os.path.splitext(basename)
if not ext: if not ext:
@@ -4528,7 +4798,9 @@ def pad_image(filename: str, req: PadRequest):
if req.fill == "transparent": if req.fill == "transparent":
fmt = "PNG" # JPEG cannot store alpha fmt = "PNG" # JPEG cannot store alpha
padded.save(path, format=fmt) padded.save(path, format=fmt)
_update_cached_file_meta(new_filename, exists=True)
_invalidate_static() _invalidate_static()
_metadata_executor.submit(_process_image_for_metadata, new_filename)
return { return {
"status": "success", "filename": filename, "new_filename": new_filename, "status": "success", "filename": filename, "new_filename": new_filename,
"new_url": f"/output/{new_filename}", "as_copy": req.as_copy, "new_url": f"/output/{new_filename}", "as_copy": req.as_copy,
@@ -4560,6 +4832,7 @@ def rotate_image(filename: str, req: RotateRequest):
img = Image.open(path).transpose(cw_to_transpose[deg]) img = Image.open(path).transpose(cw_to_transpose[deg])
fmt = "PNG" if path.lower().endswith(".png") else "JPEG" fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
img.save(path, format=fmt) img.save(path, format=fmt)
_update_cached_file_meta(filename, exists=True)
_invalidate_static() _invalidate_static()
return {"status": "success", "filename": filename, "degrees": deg} return {"status": "success", "filename": filename, "degrees": deg}
@@ -4573,9 +4846,12 @@ def duplicate_image(filename: str):
if not person or not person[5] or not os.path.exists(person[5]): if not person or not person[5] or not os.path.exists(person[5]):
raise HTTPException(404, "Image file not found") raise HTTPException(404, "Image file not found")
path = person[5] path = person[5]
output_dir = os.path.dirname(path) if filename.startswith("_turntable/"):
dir_part = ""
dir_part = os.path.dirname(filename) output_dir = _load_output_dir()
else:
output_dir = os.path.dirname(path)
dir_part = os.path.dirname(filename)
basename = os.path.basename(filename) basename = os.path.basename(filename)
stem, ext = os.path.splitext(basename) stem, ext = os.path.splitext(basename)
if not ext: if not ext:
@@ -4631,6 +4907,7 @@ def restore_background(filename: str):
bg.save(buf, format="PNG") bg.save(buf, format="PNG")
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(buf.getvalue()) f.write(buf.getvalue())
_update_cached_file_meta(filename, exists=True)
_invalidate_static() _invalidate_static()
return {"status": "success", "filename": filename} return {"status": "success", "filename": filename}
@@ -4904,6 +5181,8 @@ def estimate_pose(filename: str):
print(f"[pose] index save failed for {filename}: {e}") print(f"[pose] index save failed for {filename}: {e}")
# Save to DB # Save to DB
database.upsert_person(filename, pose_description=pose_desc, pose_skeleton=pose_skeleton_json) database.upsert_person(filename, pose_description=pose_desc, pose_skeleton=pose_skeleton_json)
_update_cached_file_meta(filename, exists=True)
_invalidate_static()
return { return {
"status": "success", "status": "success",
@@ -4971,6 +5250,7 @@ def _build_pose_index_task():
with _pose_index_lock: with _pose_index_lock:
_write_json(_pose_index_path(), idx) _write_json(_pose_index_path(), idx)
print(f"[pose] index build complete: {len(idx)} entries") print(f"[pose] index build complete: {len(idx)} entries")
_invalidate_static()
except Exception as e: except Exception as e:
print(f"[pose] index build failed: {e}") print(f"[pose] index build failed: {e}")
finally: finally:
@@ -5376,7 +5656,7 @@ def _pose_gen_worker(job_id: str, model_filename: str, prompt: str, seed: int,
png_bytes = _run_pipeline(model_pil, prompt, seed, MAX_AREA, extra_images=extra_images) png_bytes = _run_pipeline(model_pil, prompt, seed, MAX_AREA, extra_images=extra_images)
ts = time.strftime("%Y%m%d_%H%M%S") ts = time.strftime("%Y%m%d_%H%M%S")
dir_part = os.path.dirname(model_filename) dir_part = "" if model_filename.startswith("_turntable/") else os.path.dirname(model_filename)
basename = os.path.basename(model_filename) basename = os.path.basename(model_filename)
clean_basename = naming.get_base_name(basename) clean_basename = naming.get_base_name(basename)
if not clean_basename.lower().endswith(".png"): if not clean_basename.lower().endswith(".png"):
@@ -5405,6 +5685,7 @@ def _pose_gen_worker(job_id: str, model_filename: str, prompt: str, seed: int,
sort_order=next_order, sort_order=next_order,
source_refs=json.dumps(refs), source_refs=json.dumps(refs),
) )
_update_cached_file_meta(out_name, exists=True)
jobs[job_id]["status"] = "done" jobs[job_id]["status"] = "done"
jobs[job_id]["output"] = out_name jobs[job_id]["output"] = out_name
@@ -5747,6 +6028,234 @@ def reset_turntable(group_id: str):
return {"status": "reset", "group_id": group_id} return {"status": "reset", "group_id": group_id}
def _detect_people_count(keypoints: list) -> int:
"""Detect the number of people in an image from keypoints.
For now, we assume only one person is detected by the pose estimator.
This could be expanded to detect multiple people if needed.
"""
return 1 if keypoints else 0
def _detect_anatomical_completeness(keypoints: list) -> bool:
"""Detect if the person has complete anatomical structure.
Returns True if all major body parts are visible (head, torso, arms, legs).
Uses pose keypoint visibility to determine completeness.
"""
if not keypoints or len(keypoints) < 17:
return False
# Minimum visibility threshold for each keypoint
MIN_VISIBILITY = 0.3
# Key keypoints that indicate anatomical completeness
# Head (0), shoulders (5,6), hips (11,12), elbows (7,8), wrists (9,10), knees (13,14), ankles (15,16)
keypoint_indices = [0, 5, 6, 11, 12, 7, 8, 9, 10, 13, 14, 15, 16]
visible_count = 0
for idx in keypoint_indices:
if idx < len(keypoints) and keypoints[idx][2] >= MIN_VISIBILITY:
visible_count += 1
# If more than half of the key keypoints are visible, consider it complete
return visible_count > len(keypoint_indices) * 0.5
def _detect_facial_direction(keypoints: list) -> str:
"""Detect the facial direction from keypoints.
Returns a string describing the head orientation.
"""
if not keypoints or len(keypoints) < 17:
return "unknown"
# Key points for face direction detection
# Nose (0), left ear (3), right ear (4)
nose = keypoints[0] if len(keypoints) > 0 and keypoints[0][2] >= 0.3 else None
l_ear = keypoints[3] if len(keypoints) > 3 and keypoints[3][2] >= 0.3 else None
r_ear = keypoints[4] if len(keypoints) > 4 and keypoints[4][2] >= 0.3 else None
if not nose:
return "unknown"
# Determine face direction based on ear positions
if l_ear and r_ear:
ear_mid_x = (l_ear[0] + r_ear[0]) / 2
dx = nose[0] - ear_mid_x
if dx < -0.05:
return "looking left"
elif dx > 0.05:
return "looking right"
else:
return "looking forward"
elif l_ear and not r_ear:
return "looking strongly right"
elif r_ear and not l_ear:
return "looking strongly left"
else:
return "looking forward"
def _detect_objects(pil_img: Image.Image) -> list:
"""Detect objects in the image using WD tagger.
Returns a list of detected objects with bounding box coordinates.
"""
try:
# Run tagger with lower threshold to capture more objects
tags = _run_tagger(pil_img, threshold=0.2)
# Filter for object-related tags (general and character categories)
objects = []
for t in tags:
if t["cat"] in (0, 4): # general and character categories
# For simplicity, we'll return just the tag name with confidence
# In a more advanced implementation, we could extract bounding boxes from the model
objects.append({
"tag": t["tag"],
"score": t["score"],
"bbox": None # No bounding box available from WD tagger
})
return objects
except Exception as e:
print(f"[object-detection] Error: {e}")
return []
def _process_image_for_metadata(filename: str):
"""Process a single image to extract metadata for the knowledge base.
This function extracts people count, anatomical completeness, facial direction,
and objects from an image using pose estimation and WD tagger, as well as
the pose description and COCO-17 skeleton coordinates.
"""
if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
print(f"[metadata] Skipping non-image file: {filename}")
return None
try:
output_dir = _load_output_dir()
fpath = os.path.join(output_dir, filename)
if not os.path.exists(fpath):
return None
pil_img = Image.open(fpath)
# Get pose estimation
est = _load_pose_estimator()
if not est:
print("[metadata] No pose estimator available")
return None
infer, _ = est
people = infer(pil_img)
best_person = _best_person(people)
# Extract metadata
people_count = _detect_people_count(best_person)
anatomical_completeness = _detect_anatomical_completeness(best_person)
facial_direction = _detect_facial_direction(best_person)
# ALSO extract pose description and pose skeleton
pose_desc = None
pose_skel_json = None
if best_person is not None:
pose_desc = _describe_pose(best_person)
pose_skel_json = json.dumps(best_person)
desc = _pose_descriptor(best_person)
if desc is not None:
try:
_save_pose_index_entry(filename, desc)
except Exception as e:
print(f"[pose] index save failed for {filename}: {e}")
# Detect objects
objects = _detect_objects(pil_img)
# Update database with new metadata
database.upsert_person(
filename,
people_count=people_count,
anatomical_completeness=anatomical_completeness,
facial_direction=facial_direction,
objects=objects if objects else None,
pose_description=pose_desc,
pose_skeleton=pose_skel_json
)
_update_cached_file_meta(filename, exists=True)
_invalidate_static()
return {
"filename": filename,
"people_count": people_count,
"anatomical_completeness": anatomical_completeness,
"facial_direction": facial_direction,
"objects": objects,
"pose_description": pose_desc,
"pose_skeleton": pose_skel_json
}
except Exception as e:
print(f"[metadata] Error processing {filename}: {e}")
return None
class BackfillMetadataRequest(BaseModel):
filenames: list[str] | None = None # If None, process all images in DB
import asyncio
from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor
_metadata_executor = _ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata")
@app.post("/images/backfill-metadata")
async def backfill_metadata(req: BackfillMetadataRequest):
"""Backfill metadata for existing images in the database.
This endpoint processes all existing images to extract and store new metadata:
people count, anatomical completeness, facial direction, and objects.
"""
try:
# Get list of all image files
if req.filenames is not None:
filenames = req.filenames
else:
persons = database.list_persons()
filenames = [p[0] for p in persons if p[0].lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))]
# Process each image
processed_count = 0
failed_count = 0
results = []
loop = asyncio.get_running_loop()
for filename in filenames:
try:
result = await loop.run_in_executor(_metadata_executor, _process_image_for_metadata, filename)
if result:
results.append(result)
processed_count += 1
else:
failed_count += 1
except Exception as e:
print(f"[metadata] Failed to process {filename}: {e}")
failed_count += 1
return {
"status": "completed",
"processed": processed_count,
"failed": failed_count,
"total": len(filenames),
"results": results[:10] # Return first 10 results for preview
}
except Exception as e:
print(f"[metadata] Backfill error: {e}")
raise HTTPException(500, f"Backfill failed: {str(e)}")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"), uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),

View File

@@ -4627,3 +4627,14 @@ of serene anticipation, lips parted ever so slightly. The room around her is a v
In the void, you lay supine with legs spread wide and bent at the knees, your feet flat against the ground. The back is slightly arched, emphasizing the curve of the lower spine. Your arms are outstretched to the sides, parallel to the ground, with elbows slightly bent. The head is tilted back, eyes closed and mouth slightly open, in a state of blissful surrender. The hips are raised just enough to create tension in the lower back, enhancing the silhouette. A steel band encircles each thigh, just above the knee, cinching them tightly together, symbolizing restraint. Your skin glistens with a In the void, you lay supine with legs spread wide and bent at the knees, your feet flat against the ground. The back is slightly arched, emphasizing the curve of the lower spine. Your arms are outstretched to the sides, parallel to the ground, with elbows slightly bent. The head is tilted back, eyes closed and mouth slightly open, in a state of blissful surrender. The hips are raised just enough to create tension in the lower back, enhancing the silhouette. A steel band encircles each thigh, just above the knee, cinching them tightly together, symbolizing restraint. Your skin glistens with a
sheen of perspiration, accentuating every muscle contour. Anatomically precise, hyperrealistic. sheen of perspiration, accentuating every muscle contour. Anatomically precise, hyperrealistic.
# Integrated Collar Arms Restraint
A subject kneels, your body bent into a powerful, inviting stance.
Your arms are restrained behind your back, firmly secured with an integral collar restraint, a sleek device that encircles your neck, connecting to your wrists with rigid steel bars.
Your hands are clasped together, adding to the restricted pose. Your knees are positioned under your hips, providing stability.
The back is arched slightly upward, head lifted, drawing attention to the exposed shoulders and back.
Your gaze is locked onto the viewer, a look of defiance and anticipation. The pose conveys a mix of sultry energy and
power, the steel bars creating an asymmetrical silhouette. Perfect anatomy, realistic.
# nice pose
standing, feet apart, hands on the hips. torso twisted, one shoulder forward, one back. weight shifted to one leg, opposite hip popped. waist compressed, creating a pronounced hourglass silhouette. head turned to look over the shoulder. Looking back into camera. Pinched, exaggerated. Perfect anatomy, realistic

View File

@@ -390,5 +390,214 @@ class TestAPIRegression(unittest.TestCase):
self.assertEqual(person_other[8], 0) self.assertEqual(person_other[8], 0)
self.assertEqual(person_ref[8], 1) self.assertEqual(person_ref[8], 1)
def test_06_list_images_with_bypass_static(self):
# Call with bypass_static=True
response = self.client.get("/images?archived=true&bypass_static=true")
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertIn("images", res_data)
# Find our reference image in the list
images = res_data["images"]
ref_img = next((img for img in images if img["filename"] == self.test_ref_filename), None)
self.assertIsNotNone(ref_img, "Test reference image not found in bypassed images list")
# Verify complete mapped metadata fields are correctly returned when bypassing static file
self.assertEqual(ref_img["name"], self.name)
self.assertEqual(ref_img["group_id"], self.group_id)
self.assertEqual(ref_img["prompt"], self.prompt)
self.assertEqual(ref_img["pose"], self.pose)
self.assertEqual(ref_img["group_name"], self.group_name)
self.assertEqual(ref_img["hidden"], self.hidden)
self.assertEqual(ref_img["has_background"], self.has_background)
self.assertEqual(ref_img["has_clothing"], self.has_clothing)
self.assertEqual(ref_img["is_source"], self.is_source)
self.assertEqual(ref_img["pose_description"], self.pose_description)
# Verify tags list
self.assertIn("LIKE", ref_img["tags"])
self.assertIn("21+", ref_img["tags"])
# Verify skeleton
self.assertEqual(ref_img["pose_skeleton"], self.pose_skeleton)
def test_07_concurrent_reordering_deadlock_resilience(self):
import threading
import random
import time
exceptions = []
def worker(thread_idx):
try:
for i in range(15):
# Alternate the order to trigger lock conflicts
if i % 2 == 0:
order = [self.test_other_filename, self.test_ref_filename]
else:
order = [self.test_ref_filename, self.test_other_filename]
database.set_group_order(self.group_id, order)
time.sleep(0.01)
except Exception as e:
exceptions.append((thread_idx, e))
threads = []
for j in range(5):
t = threading.Thread(target=worker, args=(j,))
threads.append(t)
t.start()
for t in threads:
t.join()
# If any deadlock or serialization error was uncaught, exceptions won't be empty
if exceptions:
for tid, err in exceptions:
print(f"[test] Thread {tid} encountered error: {err}")
self.assertEqual(len(exceptions), 0, f"Encountered concurrent set_group_order errors: {exceptions}")
def test_08_deep_metadata_exposure_and_automatic_background_processing(self):
# Trigger the /images list
response = self.client.get("/images?archived=true&bypass_static=true")
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertIn("images", res_data)
# Verify that the new keys are present in each image dict
for img in res_data["images"]:
self.assertIn("people_count", img)
self.assertIn("anatomical_completeness", img)
self.assertIn("facial_direction", img)
self.assertIn("objects", img)
# Verify that manually triggering metadata backfill for our ref image returns expected structure
backfill_payload = {"filenames": [self.test_ref_filename]}
response = self.client.post("/images/backfill-metadata", json=backfill_payload)
self.assertEqual(response.status_code, 200)
res_backfill = response.json()
self.assertEqual(res_backfill["status"], "completed")
self.assertEqual(res_backfill["total"], 1)
def test_09_upload_group_assignment(self):
from unittest.mock import patch
import io
# 1. Test skip_poses=True, group_id=None
# It should assign group_id to the base name of the uploaded file.
img_bytes = io.BytesIO()
Image.new("RGB", (100, 100), color=(0, 0, 255)).save(img_bytes, format="PNG")
img_bytes.seek(0)
response = self.client.post(
"/upload",
files={"image": ("test_upload_image.png", img_bytes, "image/png")},
data={"skip_poses": "true"}
)
self.assertEqual(response.status_code, 200)
res_data = response.json()
self.assertEqual(res_data["status"], "added")
self.assertEqual(res_data["group_id"], "test_upload_image.png")
self.created_db_rows.append(res_data["filename"])
self.created_files.append(os.path.join(self.output_dir, res_data["filename"]))
# 2. Test skip_poses=False, group_id=None (e.g. CTRL+v New group with run poses)
# It should NOT collapse to "paste.png" or "test_upload_image.png", but generate a unique up_XXXX group ID!
img_bytes2 = io.BytesIO()
Image.new("RGB", (100, 100), color=(0, 255, 255)).save(img_bytes2, format="PNG")
img_bytes2.seek(0)
with patch("edit_api._process_upload") as mock_process:
response = self.client.post(
"/upload",
files={"image": ("test_upload_image2.png", img_bytes2, "image/png")},
data={"skip_poses": "false"}
)
self.assertEqual(response.status_code, 200)
res_data2 = response.json()
self.assertEqual(res_data2["status"], "processing")
# It should generate a unique ID starting with up_
self.assertTrue(res_data2["group_id"].startswith("up_"))
self.assertNotEqual(res_data2["group_id"], "test_upload_image2.png")
# Verify background task was called with the unique group_id
mock_process.assert_called_once()
args, kwargs = mock_process.call_args
# group_id is the 5th positional arg or a kwarg
called_gid = kwargs.get("group_id") or args[4]
self.assertTrue(called_gid.startswith("up_"))
self.created_files.append(os.path.join(self.output_dir, res_data2["filename"]))
def test_10_idle_backfill_non_image_filtering(self):
from unittest.mock import patch, MagicMock
import edit_api
# Reset failed backfill filenames set before testing
edit_api._failed_backfill_filenames.clear()
# Mock database.list_persons to return rows with:
# 1. A video file
# 2. A non-image file
# 3. An image that does exist on disk but we want to backfill
# 4. A normal image where people_count is already present
mock_persons = [
("video.mp4", "Video", "g1", None, "", None, 0, "", False, False, None, False, "video", None, False, False, [], None, None, None, None, None, []),
("notes.txt", "Text", "g1", None, "", None, 1, "", False, False, None, False, "image", None, False, False, [], None, None, None, None, None, []),
("processed_image.png", "Image1", "g1", None, "", None, 2, "", False, False, None, False, "image", None, False, False, [], None, None, 1, "Full", "Front", []),
("test_ref_image_123.png", "Image2", "g1", None, "", None, 3, "", False, False, None, False, "image", None, False, False, [], None, None, None, None, None, []),
]
# First, test the fast-fail guard inside _process_image_for_metadata directly (not mocked yet)
res_mp4 = edit_api._process_image_for_metadata("video.mp4")
self.assertIsNone(res_mp4)
res_txt = edit_api._process_image_for_metadata("notes.txt")
self.assertIsNone(res_txt)
# Let's mock os.path.exists to return True for any path we check
with patch("os.path.exists", return_value=True), \
patch("database.list_persons", return_value=mock_persons), \
patch("edit_api._process_image_for_metadata") as mock_process:
# Second, let's replicate/test the candidate selection loop inside edit_api._idle_turntable_daemon
output_dir = "/mnt/zim/tour-comfy/output"
_failed_backfill_filenames = edit_api._failed_backfill_filenames
persons = mock_persons
legacy_candidate = None
for row in persons:
fname = row[0]
content_type = row[12] if len(row) > 12 else None
people_count = row[19] if len(row) > 19 else None
if fname.startswith("_turntable/"):
continue
if content_type == 'video':
continue
if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
continue
fpath = os.path.join(output_dir, fname)
if not os.path.exists(fpath):
continue
if people_count is None and fname not in _failed_backfill_filenames:
legacy_candidate = fname
break
self.assertEqual(legacy_candidate, "test_ref_image_123.png")
# Let's test failed backfill insertion if _process_image_for_metadata returns None
mock_process.return_value = None
# Simulating the idle turntable loop processing the candidate
if legacy_candidate:
try:
res = edit_api._process_image_for_metadata(legacy_candidate)
if res is None:
_failed_backfill_filenames.add(legacy_candidate)
except Exception as ex:
_failed_backfill_filenames.add(legacy_candidate)
self.assertIn("test_ref_image_123.png", _failed_backfill_filenames)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -168,9 +168,12 @@
} }
container.innerHTML = files.map(f => ` container.innerHTML = files.map(f => `
<div class="item"> <div class="item">
<div> <div style="display:flex;align-items:center">
<div class="filename">${f.filename}</div> <img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div class="info">Name: ${f.name || 'none'}</div> <div>
<div class="filename">${f.filename}</div>
<div class="info">Name: ${f.name || 'none'}</div>
</div>
</div> </div>
<button class="btn success" onclick="repair('${f.filename}', 'assign_group')">Auto-assign Group</button> <button class="btn success" onclick="repair('${f.filename}', 'assign_group')">Auto-assign Group</button>
</div> </div>
@@ -185,9 +188,12 @@
} }
container.innerHTML = files.map(f => ` container.innerHTML = files.map(f => `
<div class="item"> <div class="item">
<div> <div style="display:flex;align-items:center">
<div class="filename">${f.filename}</div> <img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div> <div>
<div class="filename">${f.filename}</div>
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
</div>
</div> </div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<button class="btn success" onclick="repair('${f.filename}', 'restore')">Restore</button> <button class="btn success" onclick="repair('${f.filename}', 'restore')">Restore</button>
@@ -205,9 +211,12 @@
} }
container.innerHTML = files.map(f => ` container.innerHTML = files.map(f => `
<div class="item"> <div class="item">
<div> <div style="display:flex;align-items:center">
<div class="filename">${f.filename}</div> <img src="${API}/output/${encodeURIComponent(f.filename)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div> <div>
<div class="filename">${f.filename}</div>
<div class="info">Group: ${f.group_id || 'none'} | Name: ${f.name || 'none'}</div>
</div>
</div> </div>
<button class="btn danger" onclick="repair('${f.filename}', 'delete_record')">Delete Record</button> <button class="btn danger" onclick="repair('${f.filename}', 'delete_record')">Delete Record</button>
</div> </div>
@@ -222,7 +231,10 @@
} }
container.innerHTML = files.map(f => ` container.innerHTML = files.map(f => `
<div class="item"> <div class="item">
<div class="filename">${f}</div> <div style="display:flex;align-items:center">
<img src="${API}/output/${encodeURIComponent(f)}" style="height:50px;width:50px;object-fit:cover;border-radius:4px;margin-right:12px;background:#222;border:1px solid #333" onerror="this.style.display='none'">
<div class="filename">${f}</div>
</div>
<button class="btn success" onclick="repair('${f}', 'import_file')">Import File</button> <button class="btn success" onclick="repair('${f}', 'import_file')">Import File</button>
</div> </div>
`).join(''); `).join('');

115
xhamster_downloader.py Normal file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
XHamster Video Downloader using yt-dlp
This script downloads videos from xhamster2.com using the yt-dlp library.
Please be aware of the legal and ethical considerations when downloading content.
Usage:
python xhamster_downloader.py <video_url>
Example:
python xhamster_downloader.py "https://xhamster2.com/videos/step-sis-will-do-anything-to-make-me-delete-this-videos-xhGt5fJ"
"""
import sys
import os
import yt_dlp
import logging
import argparse
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('xhamster_downloader.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
def download_xhamster_video(url, output_path=None, quality="720p"):
"""
Download a video from xhamster2.com using yt-dlp
Args:
url (str): The URL of the xhamster video to download
output_path (str): Optional custom output path
quality (str): Video quality (default: 720p)
Returns:
bool: True if successful, False otherwise
"""
# Format selector configuration
format_selector = 'best'
if quality != 'best':
if quality.endswith('p'):
height = quality[:-1]
format_selector = f'bestvideo[height<={height}]+bestaudio/best[height<={height}]'
else:
format_selector = quality
# Configure yt-dlp options
ydl_opts = {
'format': format_selector,
'outtmpl': output_path or '%(title)s.%(ext)s',
'noplaylist': True, # Download only the video, not playlists
'nocheckcertificate': True, # Skip SSL certificate verification if needed
'verbose': False, # Set to True for detailed logging
'progress_hooks': [lambda d: logger.info(f"Progress: {d['status']} - {d.get('downloaded_bytes', 0)}/{d.get('total_bytes', 'unknown')} bytes")],
'postprocessors': [{
'key': 'FFmpegVideoConvertor',
'preferedformat': 'mp4'
}],
}
try:
logger.info(f"Starting download for: {url}")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
logger.info("Download completed successfully!")
return True
except yt_dlp.utils.DownloadError as e:
logger.error(f"Download error: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error: {e}")
return False
def main():
"""Main function to handle command line arguments and execute download"""
parser = argparse.ArgumentParser(description="XHamster Video Downloader")
parser.add_argument("url", help="URL of the video to download")
parser.add_argument("-q", "--quality", default="720p", help="Video quality (e.g. best, 1080p, 720p, 480p) (default: 720p)")
args = parser.parse_args()
video_url = args.url
# Validate URL
if not video_url.startswith("http"):
print("Error: Please provide a valid URL starting with http:// or https://")
sys.exit(1)
logger.info("XHamster Video Downloader Started")
logger.info(f"Target URL: {video_url}")
success = download_xhamster_video(video_url, quality=args.quality)
if success:
logger.info("Download process completed successfully!")
print("Video downloaded successfully!")
else:
logger.error("Download process failed!")
print("Failed to download video.")
sys.exit(1)
if __name__ == "__main__":
main()