updates UI
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,17 @@ def migrate_schema():
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
# Create prompt table first
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prompt (
|
||||
id SERIAL PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
prompt_text TEXT NOT NULL,
|
||||
metadata JSONB DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_prompt_type_text UNIQUE (type, prompt_text)
|
||||
)
|
||||
""")
|
||||
for sql in [
|
||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT",
|
||||
"ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT",
|
||||
@@ -262,6 +273,11 @@ def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None,
|
||||
pose_description, pose_skeleton, people_count, anatomical_completeness, facial_direction,
|
||||
json.dumps(objects) if objects else None))
|
||||
conn.commit()
|
||||
# Sync after commit
|
||||
try:
|
||||
sync_by_filename_async(filename)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
@@ -272,6 +288,10 @@ def set_archived(filename, archived: bool):
|
||||
try:
|
||||
cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename))
|
||||
conn.commit()
|
||||
try:
|
||||
sync_by_filename_async(filename)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
@@ -286,6 +306,11 @@ def set_filenames_archived(filenames, archived: bool):
|
||||
rows = cur.fetchall()
|
||||
updated = [r[0] for r in rows]
|
||||
conn.commit()
|
||||
for fn in updated:
|
||||
try:
|
||||
sync_by_filename_async(fn)
|
||||
except Exception:
|
||||
pass
|
||||
return updated
|
||||
finally:
|
||||
cur.close()
|
||||
@@ -297,6 +322,10 @@ def set_hidden(filename, hidden: bool):
|
||||
try:
|
||||
cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename))
|
||||
conn.commit()
|
||||
try:
|
||||
sync_by_filename_async(filename)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
@@ -307,6 +336,10 @@ def set_person_tags(filename, tags):
|
||||
try:
|
||||
cur.execute("UPDATE person SET tags = %s WHERE filename = %s", (json.dumps(tags) if tags is not None else None, filename))
|
||||
conn.commit()
|
||||
try:
|
||||
sync_by_filename_async(filename)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
@@ -363,21 +396,56 @@ def search_similar(embedding, limit=10):
|
||||
_put_db_connection(conn)
|
||||
|
||||
def delete_person(filename):
|
||||
person = get_person(filename)
|
||||
gid = person[1] if person else None
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("DELETE FROM person WHERE filename = %s", (filename,))
|
||||
conn.commit()
|
||||
# Delete JSON file on disk
|
||||
try:
|
||||
out_dir = _get_output_dir()
|
||||
base, _ = os.path.splitext(filename)
|
||||
json_path = os.path.join(out_dir, base + ".json")
|
||||
if os.path.exists(json_path):
|
||||
os.remove(json_path)
|
||||
except Exception:
|
||||
pass
|
||||
if gid:
|
||||
try:
|
||||
sync_group_to_json_async(gid)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
def delete_group(group_id):
|
||||
files = get_group_files(group_id)
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,))
|
||||
conn.commit()
|
||||
# Delete JSON files on disk
|
||||
out_dir = _get_output_dir()
|
||||
for f in files:
|
||||
try:
|
||||
base, _ = os.path.splitext(f[0])
|
||||
json_path = os.path.join(out_dir, base + ".json")
|
||||
if os.path.exists(json_path):
|
||||
os.remove(json_path)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
group_json_path = os.path.join(out_dir, "_data", f"group_{group_id}.json")
|
||||
if os.path.exists(group_json_path):
|
||||
os.remove(group_json_path)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
@@ -430,6 +498,13 @@ def set_group_order(group_id, ordered_filenames):
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
try:
|
||||
sync_group_to_json_async(group_id)
|
||||
for fname in ordered_filenames:
|
||||
sync_person_to_json_async(fname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_group_order(group_id):
|
||||
"""Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST."""
|
||||
@@ -454,6 +529,13 @@ def set_group_name(group_id, name):
|
||||
try:
|
||||
cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id))
|
||||
conn.commit()
|
||||
try:
|
||||
sync_group_to_json_async(group_id)
|
||||
files = get_group_files(group_id)
|
||||
for f in files:
|
||||
sync_person_to_json_async(f[0])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
@@ -560,3 +642,367 @@ def invalidate_all_metadata():
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
import os
|
||||
import math
|
||||
|
||||
def _get_output_dir():
|
||||
# Load config.json to find output_dir
|
||||
config_paths = ["config.json", "tour-comfy/config.json", "../config.json"]
|
||||
for p in config_paths:
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "r") as f:
|
||||
cfg = json.load(f)
|
||||
if "output_dir" in cfg:
|
||||
return cfg["output_dir"]
|
||||
except Exception:
|
||||
pass
|
||||
return "../output" # Default fallback
|
||||
|
||||
def sync_person_to_json(filename):
|
||||
"""Write/sync a detailed .json file with the DB fields of the specified filename."""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT filename, filepath, name, group_id, tags, embedding,
|
||||
clip_description, prompt, pose, sort_order, group_name, hidden,
|
||||
has_background, source_refs, has_clothing, content_type,
|
||||
faceswap_source_video, archived, face_embedding, is_source,
|
||||
pose_description, pose_skeleton, people_count, anatomical_completeness,
|
||||
facial_direction, objects
|
||||
FROM person
|
||||
WHERE filename = %s
|
||||
""", (filename,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return
|
||||
|
||||
# Parse tags
|
||||
tags_val = row[4]
|
||||
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
|
||||
|
||||
# Parse objects
|
||||
obj_val = row[25]
|
||||
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
|
||||
|
||||
# Parse source_refs
|
||||
ref_val = row[13]
|
||||
ref_list = []
|
||||
if ref_val:
|
||||
if isinstance(ref_val, str):
|
||||
try: ref_list = json.loads(ref_val)
|
||||
except Exception: ref_list = []
|
||||
elif isinstance(ref_val, list):
|
||||
ref_list = ref_val
|
||||
|
||||
# Represent embeddings as lists of floats (for json compatibility)
|
||||
embedding_list = None
|
||||
if row[5] is not None:
|
||||
if isinstance(row[5], str):
|
||||
embedding_list = [float(x) for x in row[5].strip("[]").split(",") if x.strip()]
|
||||
else:
|
||||
embedding_list = list(row[5])
|
||||
|
||||
face_embedding_list = None
|
||||
if row[18] is not None:
|
||||
if isinstance(row[18], str):
|
||||
face_embedding_list = [float(x) for x in row[18].strip("[]").split(",") if x.strip()]
|
||||
else:
|
||||
face_embedding_list = list(row[18])
|
||||
|
||||
# Parse pose_skeleton
|
||||
pose_skel = None
|
||||
if row[21]:
|
||||
try:
|
||||
pose_skel = json.loads(row[21])
|
||||
except Exception:
|
||||
pose_skel = row[21]
|
||||
|
||||
data = {
|
||||
"filename": row[0],
|
||||
"filepath": row[1],
|
||||
"name": row[2],
|
||||
"group_id": row[3],
|
||||
"tags": tags_list,
|
||||
"embedding": embedding_list,
|
||||
"clip_description": row[6],
|
||||
"prompt": row[7],
|
||||
"pose": row[8],
|
||||
"sort_order": row[9],
|
||||
"group_name": row[10],
|
||||
"hidden": bool(row[11]) if row[11] is not None else False,
|
||||
"has_background": bool(row[12]) if row[12] is not None else True,
|
||||
"source_refs": ref_list,
|
||||
"has_clothing": row[14],
|
||||
"content_type": row[15] or "image",
|
||||
"faceswap_source_video": row[16],
|
||||
"archived": bool(row[17]) if row[17] is not None else False,
|
||||
"face_embedding": face_embedding_list,
|
||||
"is_source": bool(row[19]) if row[19] is not None else False,
|
||||
"pose_description": row[20],
|
||||
"pose_skeleton": pose_skel,
|
||||
"people_count": row[22],
|
||||
"anatomical_completeness": row[23],
|
||||
"facial_direction": row[24],
|
||||
"objects": obj_list
|
||||
}
|
||||
|
||||
# Determine json path
|
||||
out_dir = _get_output_dir()
|
||||
if row[1] and os.path.isabs(row[1]):
|
||||
# Use same directory as absolute filepath
|
||||
base, _ = os.path.splitext(row[1])
|
||||
json_path = base + ".json"
|
||||
else:
|
||||
# Fallback to output_dir
|
||||
base, _ = os.path.splitext(row[0])
|
||||
json_path = os.path.join(out_dir, base + ".json")
|
||||
|
||||
os.makedirs(os.path.dirname(json_path), exist_ok=True)
|
||||
tmp_json_path = json_path + ".tmp"
|
||||
with open(tmp_json_path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
os.replace(tmp_json_path, json_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[db] Error syncing person {filename} to JSON: {e}")
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
def sync_group_to_json(group_id):
|
||||
"""Sync all members of a group, calculate group rating and similarity variation,
|
||||
and save to _data/group_<group_id>.json.
|
||||
"""
|
||||
if not group_id:
|
||||
return
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
# Fetch all members
|
||||
cur.execute("""
|
||||
SELECT filename, filepath, name, tags, embedding,
|
||||
clip_description, prompt, pose, sort_order, group_name, hidden,
|
||||
has_background, source_refs, content_type, faceswap_source_video,
|
||||
archived, face_embedding, is_source, pose_description, pose_skeleton
|
||||
FROM person
|
||||
WHERE group_id = %s
|
||||
ORDER BY sort_order ASC, filename ASC
|
||||
""", (group_id,))
|
||||
rows = cur.fetchall()
|
||||
|
||||
members = []
|
||||
likes = 0
|
||||
dislikes = 0
|
||||
clip_embs = []
|
||||
face_embs = []
|
||||
|
||||
for r in rows:
|
||||
tags_val = r[3]
|
||||
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
|
||||
|
||||
if "LIKE" in tags_list:
|
||||
likes += 1
|
||||
elif "DISLIKE" in tags_list:
|
||||
dislikes += 1
|
||||
|
||||
# Parse embeddings for similarity
|
||||
if r[4] is not None:
|
||||
if isinstance(r[4], str):
|
||||
clip_list = [float(x) for x in r[4].strip("[]").split(",") if x.strip()]
|
||||
else:
|
||||
clip_list = list(r[4])
|
||||
if clip_list:
|
||||
clip_embs.append(clip_list)
|
||||
|
||||
if r[16] is not None:
|
||||
if isinstance(r[16], str):
|
||||
face_list = [float(x) for x in r[16].strip("[]").split(",") if x.strip()]
|
||||
else:
|
||||
face_list = list(r[16])
|
||||
if face_list:
|
||||
face_embs.append(face_list)
|
||||
|
||||
members.append({
|
||||
"filename": r[0],
|
||||
"filepath": r[1],
|
||||
"name": r[2],
|
||||
"tags": tags_list,
|
||||
"clip_description": r[5],
|
||||
"prompt": r[6],
|
||||
"pose": r[7],
|
||||
"sort_order": r[8],
|
||||
"hidden": bool(r[10]),
|
||||
"archived": bool(r[15]),
|
||||
"is_source": bool(r[17])
|
||||
})
|
||||
|
||||
def cosine_similarity(v1, v2):
|
||||
if not v1 or not v2: return 0.0
|
||||
dot = sum(a * b for a, b in zip(v1, v2))
|
||||
norm1 = math.sqrt(sum(a * a for a in v1))
|
||||
norm2 = math.sqrt(sum(b * b for b in v2))
|
||||
if norm1 == 0 or norm2 == 0: return 0.0
|
||||
return dot / (norm1 * norm2)
|
||||
|
||||
def calc_stats(embs):
|
||||
if len(embs) < 2:
|
||||
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
|
||||
similarities = []
|
||||
for i in range(len(embs)):
|
||||
for j in range(i + 1, len(embs)):
|
||||
similarities.append(cosine_similarity(embs[i], embs[j]))
|
||||
if not similarities:
|
||||
return {"average": 1.0, "min": 1.0, "max": 1.0, "variation": 0.0, "pairs_count": 0}
|
||||
avg_sim = sum(similarities) / len(similarities)
|
||||
min_sim = min(similarities)
|
||||
max_sim = max(similarities)
|
||||
variance = sum((s - avg_sim) ** 2 for s in similarities) / len(similarities)
|
||||
return {
|
||||
"average": float(avg_sim),
|
||||
"min": float(min_sim),
|
||||
"max": float(max_sim),
|
||||
"variation": float(math.sqrt(variance)),
|
||||
"pairs_count": len(similarities)
|
||||
}
|
||||
|
||||
group_name = rows[0][9] if rows else group_id
|
||||
|
||||
group_data = {
|
||||
"group_id": group_id,
|
||||
"group_name": group_name,
|
||||
"rating": {
|
||||
"likes": likes,
|
||||
"dislikes": dislikes,
|
||||
"score": likes - dislikes,
|
||||
"normalized_score": (likes - dislikes) / (likes + dislikes + 2.0) if (likes + dislikes) > 0 else 0.0,
|
||||
"ratio": likes / (likes + dislikes) if (likes + dislikes) > 0 else 0.0
|
||||
},
|
||||
"clip_similarity_stats": calc_stats(clip_embs),
|
||||
"face_similarity_stats": calc_stats(face_embs),
|
||||
"members": members
|
||||
}
|
||||
|
||||
out_dir = _get_output_dir()
|
||||
data_dir = os.path.join(out_dir, "_data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
json_path = os.path.join(data_dir, f"group_{group_id}.json")
|
||||
tmp_json_path = json_path + ".tmp"
|
||||
with open(tmp_json_path, "w") as f:
|
||||
json.dump(group_data, f, indent=2)
|
||||
os.replace(tmp_json_path, json_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[db] Error syncing group {group_id} to JSON: {e}")
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
def sync_by_filename(filename):
|
||||
sync_person_to_json(filename)
|
||||
person = get_person(filename)
|
||||
if person and person[1]:
|
||||
sync_group_to_json(person[1])
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
_sync_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="db_sync_worker")
|
||||
|
||||
def sync_by_filename_async(filename):
|
||||
_sync_executor.submit(sync_by_filename, filename)
|
||||
|
||||
def sync_group_to_json_async(group_id):
|
||||
_sync_executor.submit(sync_group_to_json, group_id)
|
||||
|
||||
def sync_person_to_json_async(filename):
|
||||
_sync_executor.submit(sync_person_to_json, filename)
|
||||
|
||||
|
||||
def save_db_prompt(prompt_type: str, prompt_text: str, metadata: dict = None):
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute("""
|
||||
INSERT INTO prompt (type, prompt_text, metadata, created_at)
|
||||
VALUES (%s, %s, %s, NOW())
|
||||
ON CONFLICT (type, prompt_text) DO UPDATE
|
||||
SET metadata = EXCLUDED.metadata,
|
||||
created_at = NOW()
|
||||
""", (prompt_type, prompt_text, json.dumps(metadata) if metadata is not None else None))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
print(f"[db] error saving prompt {prompt_type}: {e}")
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
def list_db_prompts(prompt_type: str = None, limit: int = 100):
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
if prompt_type:
|
||||
cur.execute("""
|
||||
SELECT id, type, prompt_text, metadata, created_at
|
||||
FROM prompt
|
||||
WHERE type = %s
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""", (prompt_type, limit))
|
||||
else:
|
||||
cur.execute("""
|
||||
SELECT id, type, prompt_text, metadata, created_at
|
||||
FROM prompt
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""", (limit,))
|
||||
|
||||
rows = cur.fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
meta = r[3]
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = json.loads(meta)
|
||||
except Exception:
|
||||
meta = {}
|
||||
elif meta is None:
|
||||
meta = {}
|
||||
result.append({
|
||||
"id": r[0],
|
||||
"type": r[1],
|
||||
"prompt_text": r[2],
|
||||
"metadata": meta,
|
||||
"created_at": r[4].isoformat() if r[4] else None
|
||||
})
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[db] error listing prompts: {e}")
|
||||
return []
|
||||
finally:
|
||||
cur.close()
|
||||
_put_db_connection(conn)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Install/refresh the systemd services for Qwen-Image-Edit on THIS host.
|
||||
# Host-agnostic: the service user, group and install path are derived at run
|
||||
# time, so the same file works on tour, hubby, etc.
|
||||
#
|
||||
# Run with sudo (needs to write /etc/systemd/system). Assumes bootstrap.sh has
|
||||
# already created venv/, ComfyUI/ and the models under BASE.
|
||||
set -e
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # .../comfyui/api
|
||||
BASE="$( cd "$SCRIPT_DIR/.." && pwd )" # .../comfyui
|
||||
TEMPLATES="$SCRIPT_DIR/systemd"
|
||||
|
||||
# The service should run as the owner of the project, not root.
|
||||
SVC_USER="${SUDO_USER:-$(stat -c '%U' "$SCRIPT_DIR")}"
|
||||
SVC_GROUP="$(id -gn "$SVC_USER")"
|
||||
|
||||
echo "Installing services: user=$SVC_USER group=$SVC_GROUP base=$BASE"
|
||||
|
||||
for unit in comfyui-backend comfyui-api; do
|
||||
sed -e "s|__USER__|$SVC_USER|g" \
|
||||
-e "s|__GROUP__|$SVC_GROUP|g" \
|
||||
-e "s|__BASE__|$BASE|g" \
|
||||
"$TEMPLATES/$unit.service" > "/etc/systemd/system/$unit.service"
|
||||
echo " wrote /etc/systemd/system/$unit.service"
|
||||
done
|
||||
|
||||
echo "Reloading systemd daemon..."
|
||||
systemctl daemon-reload
|
||||
|
||||
echo "Enabling + (re)starting services..."
|
||||
systemctl enable comfyui-backend.service comfyui-api.service
|
||||
systemctl restart comfyui-backend.service comfyui-api.service
|
||||
|
||||
echo "Deployment complete."
|
||||
echo "Check status with: systemctl status comfyui-backend comfyui-api"
|
||||
@@ -50,9 +50,13 @@ WORKFLOW_PATH = os.environ.get(
|
||||
"WORKFLOW_PATH",
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"),
|
||||
)
|
||||
# Default target pixel area for the output latent. The MI50 is not fast, so we
|
||||
# cap at ~1MP by default; raise via MAX_AREA env if you want bigger output.
|
||||
MAX_AREA = int(os.environ.get("MAX_AREA", str(1024 * 1024)))
|
||||
# Default target pixel area for the output latent.
|
||||
# We currently cap at ~1MP by default; raise via MAX_AREA env if you want bigger output.
|
||||
# A6000 48GB is not VRAM-bound here, so default to a ~2MP output budget.
|
||||
# This comfortably allows full-HD-ish outputs like 1920x1080.
|
||||
# Override via MAX_AREA when needed.
|
||||
#export MAX_AREA="${MAX_AREA:-2097152}"
|
||||
MAX_AREA = int(os.environ.get("MAX_AREA", str(2097152)))
|
||||
GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request
|
||||
|
||||
# Node ids in workflow_qwen_edit.json (kept stable on purpose).
|
||||
@@ -1758,6 +1762,23 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
if jobs[job_id].get("cancelled"):
|
||||
return
|
||||
try:
|
||||
try:
|
||||
database.save_db_prompt("pose-prompt", prompt, {
|
||||
"pose": pose,
|
||||
"seed": seed,
|
||||
"max_area": max_area,
|
||||
"wireframe_ref": wireframe_ref,
|
||||
"wireframe_time": wireframe_time,
|
||||
"pad_top": pad_top,
|
||||
"pad_right": pad_right,
|
||||
"pad_bottom": pad_bottom,
|
||||
"pad_left": pad_left,
|
||||
"pad_fill": pad_fill,
|
||||
"pad_outpaint": pad_outpaint
|
||||
})
|
||||
except Exception as db_err:
|
||||
print(f"[batch] failed to save to prompt table: {db_err}")
|
||||
|
||||
pil = base_pil
|
||||
actual_prompt = prompt
|
||||
if pad_outpaint:
|
||||
@@ -1865,6 +1886,21 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
|
||||
|
||||
for prompt, pose in zip(prompts, poses):
|
||||
try:
|
||||
try:
|
||||
database.save_db_prompt("pose-prompt", prompt, {
|
||||
"pose": pose,
|
||||
"seed": seed,
|
||||
"max_area": max_area,
|
||||
"filenames": filenames,
|
||||
"pad_top": pad_top,
|
||||
"pad_right": pad_right,
|
||||
"pad_bottom": pad_bottom,
|
||||
"pad_left": pad_left,
|
||||
"pad_fill": pad_fill,
|
||||
"pad_outpaint": pad_outpaint
|
||||
})
|
||||
except Exception as db_err:
|
||||
print(f"[multi-ref] failed to save to prompt table: {db_err}")
|
||||
work_pil = primary_pil
|
||||
actual_prompt = prompt
|
||||
if pad_outpaint:
|
||||
@@ -1947,6 +1983,23 @@ def update_config(update: ConfigUpdate):
|
||||
return {"seed": conf["seed"]}
|
||||
|
||||
|
||||
class SavePromptRequest(BaseModel):
|
||||
type: str
|
||||
prompt_text: str
|
||||
metadata: dict | None = None
|
||||
|
||||
|
||||
@app.post("/prompts")
|
||||
def api_save_prompt(req: SavePromptRequest):
|
||||
database.save_db_prompt(req.type, req.prompt_text, req.metadata)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@app.get("/prompts")
|
||||
def api_list_prompts(type: str | None = None, limit: int = 100):
|
||||
return database.list_db_prompts(type, limit)
|
||||
|
||||
|
||||
class GroupArchiveRequest(BaseModel):
|
||||
filenames: list[str]
|
||||
|
||||
@@ -2123,12 +2176,173 @@ def refine_prompt(req: RefineRequest):
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
refined = data["choices"][0]["message"]["content"].strip()
|
||||
try:
|
||||
database.save_db_prompt("refine", refined, {
|
||||
"original": req.prompt,
|
||||
"filename": req.filename
|
||||
})
|
||||
except Exception as db_err:
|
||||
print(f"[refine-prompt] failed to save to prompt table: {db_err}")
|
||||
return {"refined": refined}
|
||||
except Exception as e:
|
||||
print(f"Refinement error: {e}")
|
||||
raise HTTPException(500, f"LLM refinement failed: {str(e)}")
|
||||
|
||||
|
||||
class UpdatePromptRequest(BaseModel):
|
||||
prompt: str
|
||||
|
||||
|
||||
@app.post("/images/{filename:path}/reverse-engineer")
|
||||
def reverse_engineer(filename: str):
|
||||
person = database.get_person(filename)
|
||||
if not person:
|
||||
raise HTTPException(404, "Image not found in database")
|
||||
|
||||
# Extract metadata on the fly if pose_description is not present
|
||||
if person[15] is None:
|
||||
try:
|
||||
metadata = _process_image_for_metadata(filename)
|
||||
if metadata:
|
||||
# Reload person row
|
||||
person = database.get_person(filename)
|
||||
except Exception as e:
|
||||
print(f"Failed to process image for metadata during reverse-engineer: {e}")
|
||||
|
||||
# Build context string
|
||||
tags_val = person[2]
|
||||
clip_desc_val = person[4]
|
||||
original_prompt = person[6]
|
||||
pose_desc = person[15]
|
||||
people_count = person[17]
|
||||
anatomical_completeness = person[18]
|
||||
facial_direction = person[19]
|
||||
objects_val = person[20]
|
||||
|
||||
context_parts = []
|
||||
|
||||
# 1. Base prompt or tags
|
||||
if original_prompt:
|
||||
context_parts.append(f"Original Prompt/Tags: {original_prompt}")
|
||||
elif clip_desc_val:
|
||||
context_parts.append(f"Scene Tags Description: {clip_desc_val}")
|
||||
|
||||
# 2. WD Tagger tags
|
||||
if tags_val:
|
||||
try:
|
||||
if isinstance(tags_val, str):
|
||||
tags_list = json.loads(tags_val)
|
||||
else:
|
||||
tags_list = tags_val
|
||||
if tags_list:
|
||||
tag_names = [t["tag"] for t in tags_list if isinstance(t, dict) and "tag" in t and t.get("score", 0) > 0.35]
|
||||
if tag_names:
|
||||
context_parts.append(f"WD Tagger tags: {', '.join(tag_names[:25])}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Pose description
|
||||
if pose_desc:
|
||||
context_parts.append(f"Pose details: {pose_desc}")
|
||||
|
||||
# 4. People count
|
||||
if people_count is not None:
|
||||
context_parts.append(f"Subject count: {people_count} person(s)")
|
||||
|
||||
# 5. Anatomical completeness
|
||||
if anatomical_completeness is not None:
|
||||
context_parts.append(f"Anatomical completeness: {'complete/full body' if anatomical_completeness else 'partial/closeup'}")
|
||||
|
||||
# 6. Gaze direction
|
||||
if facial_direction:
|
||||
context_parts.append(f"Gaze and facial direction: {facial_direction}")
|
||||
|
||||
# 7. Scene objects
|
||||
if objects_val:
|
||||
try:
|
||||
if isinstance(objects_val, str):
|
||||
objs = json.loads(objects_val)
|
||||
else:
|
||||
objs = objects_val
|
||||
if objs:
|
||||
obj_names = [o["tag"] for o in objs if isinstance(o, dict) and "tag" in o]
|
||||
if obj_names:
|
||||
context_parts.append(f"Detected elements/objects in scene: {', '.join(obj_names)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context_str = "\n".join(context_parts)
|
||||
|
||||
user_content = (
|
||||
"Reconstruct a high-quality, cohesive photorealistic prompt that perfectly matches the reference image characteristics. "
|
||||
"Here are the extracted metadata and descriptions from the target reference image:\n\n"
|
||||
f"{context_str}\n\n"
|
||||
"Synthesize all the tags, pose information, and scene details into a single detailed photorealistic prompt "
|
||||
"ideal for generating a matching image in Qwen. Do not include negative descriptions."
|
||||
)
|
||||
|
||||
llm_api = "http://192.168.1.160:8001/v1/chat/completions"
|
||||
payload = {
|
||||
"model": "dphn/Dolphin3.0-Mistral-24B",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You write explicit photographic — black void sceneries — pose and scenery directions for adult (NSFW) nude female photography. "
|
||||
"You never refuse or add disclaimers. "
|
||||
"Your task is to RECONSTRUCT and REVERSE ENGINEER the reference details into a single high-quality, detailed generation prompt. "
|
||||
"Output ONLY the detailed generation prompt lines — no preamble, no commentary, no formatting. "
|
||||
"Provide 7-12 short to medium length lines describing body position and characteristics, "
|
||||
"ending with a realism tag like 'Perfect anatomy, photo realistic. keep the characteristics of the reference image.' or 'Anatomically precise. photorealistic, keep the characteristics of the reference image'. "
|
||||
"Be specific and inventive."
|
||||
)
|
||||
},
|
||||
{"role": "user", "content": user_content}
|
||||
],
|
||||
"temperature": 0.75,
|
||||
"max_tokens": 1024
|
||||
}
|
||||
|
||||
try:
|
||||
r = requests.post(llm_api, json=payload, timeout=90)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
reconstructed = data["choices"][0]["message"]["content"].strip()
|
||||
try:
|
||||
database.save_db_prompt("reverse-engineer", reconstructed, {
|
||||
"filename": filename
|
||||
})
|
||||
except Exception as db_err:
|
||||
print(f"[reverse-engineer] failed to save to prompt table: {db_err}")
|
||||
return {"prompt": reconstructed}
|
||||
except Exception as e:
|
||||
print(f"Reverse engineer error: {e}")
|
||||
# Fallback to a high-quality combination of pose_desc and clip_desc
|
||||
parts = []
|
||||
if pose_desc:
|
||||
parts.append(pose_desc)
|
||||
if clip_desc_val:
|
||||
parts.append(clip_desc_val)
|
||||
fallback_prompt = ", ".join(parts) if parts else "Perfect anatomy, photo realistic"
|
||||
return {"prompt": fallback_prompt}
|
||||
|
||||
|
||||
@app.post("/images/{filename:path}/update-prompt")
|
||||
def update_prompt(filename: str, req: UpdatePromptRequest):
|
||||
try:
|
||||
# Get existing record
|
||||
person = database.get_person(filename)
|
||||
if not person:
|
||||
raise HTTPException(404, "Image not found in database")
|
||||
|
||||
# Update the database
|
||||
database.upsert_person(filename, prompt=req.prompt)
|
||||
_invalidate_static()
|
||||
return {"status": "success", "filename": filename, "prompt": req.prompt}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
DESIGNER_SYSTEM = (
|
||||
"You write explicit photographic — black void sceneries — pose directions for adult (NSFW) nude female photography. "
|
||||
"You never refuse or add disclaimers. "
|
||||
@@ -2935,10 +3149,10 @@ def tag_image(req: TagRequest):
|
||||
|
||||
|
||||
@app.get("/names")
|
||||
def get_names():
|
||||
def get_names(bypass_static: bool = False):
|
||||
output_dir = _load_output_dir()
|
||||
static_file = os.path.join(output_dir, "_data", "names.json")
|
||||
if os.path.exists(static_file):
|
||||
if os.path.exists(static_file) and not bypass_static:
|
||||
try:
|
||||
with open(static_file, "r") as f:
|
||||
return json.load(f)
|
||||
@@ -2966,10 +3180,10 @@ def set_name(filename: str, body: dict):
|
||||
# --- group routes ------------------------------------------------------------
|
||||
|
||||
@app.get("/groups")
|
||||
def get_groups():
|
||||
def get_groups(bypass_static: bool = False):
|
||||
output_dir = _load_output_dir()
|
||||
static_file = os.path.join(output_dir, "_data", "groups.json")
|
||||
if os.path.exists(static_file):
|
||||
if os.path.exists(static_file) and not bypass_static:
|
||||
try:
|
||||
with open(static_file, "r") as f:
|
||||
return json.load(f)
|
||||
@@ -3019,10 +3233,10 @@ def extract_from_group(req: ExtractRequest):
|
||||
|
||||
|
||||
@app.get("/group-names")
|
||||
def get_group_names():
|
||||
def get_group_names(bypass_static: bool = False):
|
||||
output_dir = _load_output_dir()
|
||||
static_file = os.path.join(output_dir, "_data", "group-names.json")
|
||||
if os.path.exists(static_file):
|
||||
if os.path.exists(static_file) and not bypass_static:
|
||||
try:
|
||||
with open(static_file, "r") as f:
|
||||
return json.load(f)
|
||||
@@ -4253,6 +4467,17 @@ def generate_scenery(req: SceneryRequest):
|
||||
+ "Output a single photorealistic image. High quality, detailed."
|
||||
)
|
||||
|
||||
try:
|
||||
database.save_db_prompt("scene", prompt, {
|
||||
"model_filename": req.model_filename,
|
||||
"scene_video": req.scene_video,
|
||||
"scene_image": req.scene_image,
|
||||
"extra_filename": req.extra_filename,
|
||||
"seed": req.seed
|
||||
})
|
||||
except Exception as db_err:
|
||||
print(f"[scenery] failed to save prompt: {db_err}")
|
||||
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
|
||||
threading.Thread(
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shared path resolver for the Qwen-Image-Edit service scripts.
|
||||
# Sourced by bootstrap.sh / run_comfyui.sh / start_api.sh.
|
||||
#
|
||||
# Why this exists: a Python venv CANNOT live on the NTFS (fuseblk) mount used
|
||||
# on tour (/media/tour/APPS). Its interpreter symlinks turn into
|
||||
# "unsupported reparse tag 0x..." after a reboot/remount, so `python`
|
||||
# vanishes and every service dies. ComfyUI code and the model files are plain
|
||||
# files and are fine on NTFS -- only the venv must be on a native fs.
|
||||
#
|
||||
# So: if BASE is on a non-native filesystem, the venv goes under $HOME (ext4);
|
||||
# otherwise it stays at $BASE/venv. Override explicitly with COMFY_VENV.
|
||||
|
||||
ENV_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # .../comfyui/api
|
||||
API_DIR="$ENV_DIR"
|
||||
BASE="$( cd "$ENV_DIR/.." && pwd )" # .../comfyui
|
||||
COMFY="$BASE/ComfyUI"
|
||||
|
||||
_basefs="$(stat -f -c %T "$BASE" 2>/dev/null || echo unknown)"
|
||||
case "$_basefs" in
|
||||
fuseblk|ntfs|ntfs3|exfat|vfat|msdos|9p|cifs|smb*)
|
||||
VENV="${COMFY_VENV:-/home/mike/comfyui/venv}" ;; # NTFS-ish BASE -> venv on home
|
||||
*)
|
||||
if [ -d "/home/mike/comfyui/venv" ]; then
|
||||
VENV="${COMFY_VENV:-/home/mike/comfyui/venv}"
|
||||
else
|
||||
VENV="${COMFY_VENV:-$BASE/venv}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Install FaceFusion 3.x for high-quality face+hair swap.
|
||||
# Clones into ~/facefusion and creates a dedicated venv at ~/facefusion-venv.
|
||||
# Usage: bash tour-comfy/install_facefusion.sh
|
||||
set -e
|
||||
|
||||
FF_DIR="${FACEFUSION_DIR:-$HOME/facefusion}"
|
||||
FF_VENV="${FACEFUSION_VENV:-$HOME/facefusion-venv}"
|
||||
FF_REPO="https://github.com/facefusion/facefusion"
|
||||
|
||||
echo "[facefusion] Installing to $FF_DIR (venv: $FF_VENV)"
|
||||
|
||||
# 1. Clone or update
|
||||
if [ -d "$FF_DIR/.git" ]; then
|
||||
echo "[facefusion] Updating existing clone ..."
|
||||
git -C "$FF_DIR" pull --ff-only
|
||||
else
|
||||
echo "[facefusion] Cloning $FF_REPO ..."
|
||||
git clone "$FF_REPO" "$FF_DIR"
|
||||
fi
|
||||
|
||||
# 2. Create dedicated venv (avoids dependency conflicts with ComfyUI)
|
||||
if [ ! -d "$FF_VENV" ]; then
|
||||
echo "[facefusion] Creating venv at $FF_VENV ..."
|
||||
python3 -m venv "$FF_VENV"
|
||||
fi
|
||||
|
||||
PIP="$FF_VENV/bin/pip"
|
||||
PY="$FF_VENV/bin/python"
|
||||
|
||||
"$PIP" install --upgrade pip wheel
|
||||
|
||||
# 3. Install FaceFusion requirements
|
||||
cd "$FF_DIR"
|
||||
"$PIP" install -r requirements.txt \
|
||||
--extra-index-url https://download.pytorch.org/whl/cu124
|
||||
|
||||
# 4. Download base models (ghost_3_1_256 + gfpgan_1.4 for enhance)
|
||||
echo "[facefusion] Downloading default models via FaceFusion model manager ..."
|
||||
"$PY" facefusion.py \
|
||||
--processors face_swapper hair_swapper face_enhancer \
|
||||
--face-swapper-model ghost_3_1_256 \
|
||||
--face-enhancer-model gfpgan_1.4 \
|
||||
--execution-providers cpu \
|
||||
download-models 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "[facefusion] Installation complete."
|
||||
echo " Binary: $PY $FF_DIR/facefusion.py"
|
||||
echo " Config: set facefusion_dir/facefusion_venv in tour-comfy/config.json"
|
||||
echo ""
|
||||
echo "Restart the API (start_api.sh) and the 'Hair swap' toggle will activate."
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Install GFPGAN face enhancement into the ComfyUI venv.
|
||||
# Applies two patches for Python 3.13 + newer torchvision compatibility.
|
||||
# Usage: bash tour-comfy/install_gfpgan.sh
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
source "$SCRIPT_DIR/env.sh"
|
||||
source "$VENV/bin/activate"
|
||||
|
||||
PYTHON="$VENV/bin/python"
|
||||
PIP="$VENV/bin/pip"
|
||||
|
||||
echo "[gfpgan] Step 1 — install basicsr (with Python 3.13 patch) ..."
|
||||
TMPDIR=$(mktemp -d)
|
||||
curl -sL "https://pypi.io/packages/source/b/basicsr/basicsr-1.4.2.tar.gz" -o "$TMPDIR/basicsr-1.4.2.tar.gz"
|
||||
tar -xzf "$TMPDIR/basicsr-1.4.2.tar.gz" -C "$TMPDIR"
|
||||
|
||||
# Patch 1: fix get_version() — exec() doesn't update locals() in Python 3
|
||||
"$PYTHON" - <<'PYPATCH'
|
||||
import sys, re
|
||||
setup = sys.argv[1]
|
||||
with open(setup) as f:
|
||||
content = f.read()
|
||||
old = "def get_version():\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'))\n return locals()['__version__']"
|
||||
new = "def get_version():\n if not os.path.exists(version_file):\n write_version_py()\n globs = {}\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'), globs)\n return globs['__version__']"
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
with open(setup, 'w') as f:
|
||||
f.write(content)
|
||||
print(' Patched setup.py get_version()')
|
||||
else:
|
||||
print(' setup.py pattern not found, skipping patch')
|
||||
PYPATCH
|
||||
"$TMPDIR/basicsr-1.4.2/setup.py" -- "$TMPDIR/basicsr-1.4.2/setup.py" 2>/dev/null || true
|
||||
"$PYTHON" - "$TMPDIR/basicsr-1.4.2/setup.py" <<'PYPATCH'
|
||||
import sys, re, os
|
||||
setup = sys.argv[1]
|
||||
with open(setup) as f:
|
||||
content = f.read()
|
||||
old = "def get_version():\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'))\n return locals()['__version__']"
|
||||
new = "def get_version():\n if not os.path.exists(version_file):\n write_version_py()\n globs = {}\n with open(version_file, 'r') as f:\n exec(compile(f.read(), version_file, 'exec'), globs)\n return globs['__version__']"
|
||||
if old in content:
|
||||
content = content.replace(old, new)
|
||||
with open(setup, 'w') as f:
|
||||
f.write(content)
|
||||
print(' Patched setup.py get_version()')
|
||||
else:
|
||||
print(' setup.py already patched or pattern changed, skipping')
|
||||
PYPATCH
|
||||
|
||||
"$PIP" install "$TMPDIR/basicsr-1.4.2/" --no-build-isolation --no-deps -q
|
||||
rm -rf "$TMPDIR"
|
||||
|
||||
echo "[gfpgan] Step 2 — install facexlib and gfpgan ..."
|
||||
"$PIP" install facexlib gfpgan -q
|
||||
|
||||
# Patch 2: fix torchvision functional_tensor import (removed in newer torchvision)
|
||||
DEGR_PY="$VENV/lib/python3.13/site-packages/basicsr/data/degradations.py"
|
||||
if [ -f "$DEGR_PY" ]; then
|
||||
if grep -q "functional_tensor" "$DEGR_PY"; then
|
||||
sed -i 's/from torchvision.transforms.functional_tensor import rgb_to_grayscale/from torchvision.transforms.functional import rgb_to_grayscale/' "$DEGR_PY"
|
||||
echo "[gfpgan] Patched degradations.py functional_tensor import"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pre-download the model
|
||||
MODEL_DIR="$HOME/.gfpgan/weights"
|
||||
MODEL_PATH="$MODEL_DIR/GFPGANv1.4.pth"
|
||||
mkdir -p "$MODEL_DIR"
|
||||
if [ ! -f "$MODEL_PATH" ]; then
|
||||
echo "[gfpgan] Downloading GFPGANv1.4.pth (~333 MB) ..."
|
||||
wget -q --show-progress \
|
||||
"https://github.com/TencentARC/GFPGAN/releases/download/v1.3.4/GFPGANv1.4.pth" \
|
||||
-O "$MODEL_PATH.tmp"
|
||||
mv "$MODEL_PATH.tmp" "$MODEL_PATH"
|
||||
echo "[gfpgan] Model saved to $MODEL_PATH"
|
||||
else
|
||||
echo "[gfpgan] Model already present: $MODEL_PATH"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[gfpgan] Done. Restart the API (start_api.sh) to enable face enhancement."
|
||||
@@ -3045,13 +3045,13 @@ Eyes looking at camera, keeping your facial characteristics as reference photo.
|
||||
One bar, two bands, one lockplate — access spread open and simultaneously sealed.
|
||||
Anatomically precise, hyperrealistic, high detail, keep the characteristics of the reference image.
|
||||
|
||||
# Tampon — Internal Plug, External Shield, Integral Collar
|
||||
# Tampon — Internal Plug, Integral Collar
|
||||
|
||||
You are standing in black void.
|
||||
A single solid steel device, polished and seamless.
|
||||
An internal plug, contoured and smooth, connected by a short rigid bar to a small external shield covering your vulva.
|
||||
From the shield, a thin rigid steel bar rises up your abdomen and sternum, terminating in a collar locked around your neck.
|
||||
One continuous piece — internal plug, external shield, connecting bar, collar.
|
||||
One continuous piece — internal plug, connecting bar, collar.
|
||||
Your wrists cuffed to the vertical bar at your hips.
|
||||
Your ankles cuffed together.
|
||||
The device occupies and seals simultaneously — internal and external denial in one form.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Launch the ComfyUI backend (headless) for the Qwen-Image-Edit API.
|
||||
# gfx906 (MI50) has no flash-attention, so use the pytorch cross-attention path.
|
||||
set -e
|
||||
# env.sh resolves BASE/COMFY/VENV (and keeps the venv off NTFS). Portable
|
||||
# across hosts (tour: /media/tour/APPS/comfyui, hubby: /home/hubby/comfyui).
|
||||
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
|
||||
cd "$COMFY"
|
||||
source "$VENV/bin/activate"
|
||||
|
||||
# MI50 / Vega20 is happiest in fp16; avoid bf16 emulation.
|
||||
export PYTORCH_HIP_ALLOC_CONF="expandable_segments:True,garbage_collection_threshold:0.8"
|
||||
export HSA_ENABLE_SDMA=0
|
||||
|
||||
# Split cross-attention chunks the attention matmul -> much lower peak VRAM,
|
||||
# which is what lets the 20B Q8 edit model + reference-image sequence fit in 32GB.
|
||||
# --lowvram offloads models to CPU RAM when not in use, preventing OOM.
|
||||
exec python main.py \
|
||||
--listen 127.0.0.1 \
|
||||
--port 8188 \
|
||||
--use-split-cross-attention \
|
||||
--lowvram \
|
||||
"$@"
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Launch the FastAPI edit service (talks to the local ComfyUI on :8188).
|
||||
set -e
|
||||
# env.sh resolves API_DIR/VENV (and keeps the venv off NTFS).
|
||||
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
|
||||
source "$VENV/bin/activate"
|
||||
cd "$API_DIR"
|
||||
|
||||
# Add all nvidia CUDA library paths bundled with the venv (needed by onnxruntime-gpu / insightface)
|
||||
_NV_BASE="$VENV/lib/python3.13/site-packages/nvidia"
|
||||
_NV_LIBPATH="$_NV_BASE/cuda_runtime/lib:$_NV_BASE/cublas/lib:$_NV_BASE/cudnn/lib:$_NV_BASE/curand/lib:$_NV_BASE/cufft/lib:$_NV_BASE/cusolver/lib:$_NV_BASE/cusparse/lib:$_NV_BASE/nvjitlink/lib:$_NV_BASE/cuda_nvrtc/lib"
|
||||
export LD_LIBRARY_PATH="${_NV_LIBPATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
|
||||
export COMFY_URL="http://127.0.0.1:8188"
|
||||
export HOST="0.0.0.0"
|
||||
export PORT="8500"
|
||||
# Output pixel budget. MI50 is compute-bound on this 20B model:
|
||||
# ~0.59MP -> ~110s ~0.79MP -> ~140s ~1.0MP -> ~180s (4 steps)
|
||||
# 0.79MP is a sane speed/quality default; raise for bigger output.
|
||||
# Lowered to 0.65MP to help prevent GPU OOM on MI50.
|
||||
export MAX_AREA="${MAX_AREA:-655360}"
|
||||
|
||||
exec python edit_api.py
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Launch the folder watcher service.
|
||||
set -e
|
||||
# env.sh resolves API_DIR/VENV (and keeps the venv off NTFS).
|
||||
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
|
||||
source "$VENV/bin/activate"
|
||||
cd "$API_DIR"
|
||||
|
||||
exec python3 watcher.py
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Stop and disable systemd services for Qwen-Image-Edit
|
||||
set -e
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping services..."
|
||||
systemctl stop comfyui-api.service
|
||||
systemctl stop comfyui-backend.service
|
||||
|
||||
echo "Disabling services..."
|
||||
systemctl disable comfyui-api.service
|
||||
systemctl disable comfyui-backend.service
|
||||
|
||||
echo "Services stopped and disabled."
|
||||
114
tour-comfy/test_regression_qwen.py
Normal file
114
tour-comfy/test_regression_qwen.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import unittest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Ensure tour-comfy is in the import path
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
if _HERE not in sys.path:
|
||||
sys.path.append(_HERE)
|
||||
|
||||
from edit_api import app, _load_output_dir
|
||||
|
||||
class TestQwenBackendRegression(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.client = TestClient(app)
|
||||
cls.output_dir = _load_output_dir()
|
||||
|
||||
# Real-data filenames specified in the issue description
|
||||
cls.real_img_1 = "20260618_053519_1_20260618_053458_image.png"
|
||||
cls.real_img_2 = "20260618_052537_0_20260618_052526_image.png"
|
||||
cls.wireframe_img = "up_20260628_104641_image.png"
|
||||
|
||||
# Validate that they exist on disk to run real end-to-end tests
|
||||
cls.run_real_tests = True
|
||||
img1_path = os.path.join(cls.output_dir, cls.real_img_1)
|
||||
img2_path = os.path.join(cls.output_dir, cls.real_img_2)
|
||||
|
||||
if not os.path.exists(img1_path) or not os.path.exists(img2_path):
|
||||
cls.run_real_tests = False
|
||||
print(f"[test] Real images not found at {img1_path} or {img2_path}. Running tests with fallback/mock assertion modes.")
|
||||
|
||||
def test_multi_ref_qwen_endpoint(self):
|
||||
"""Test multi-reference generation endpoint with specified real-data images."""
|
||||
if not self.run_real_tests:
|
||||
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
|
||||
|
||||
payload = {
|
||||
"filenames": [self.real_img_1, self.real_img_2],
|
||||
"prompt": "standing girl looking at camera, high quality, highly detailed",
|
||||
"seed": 42,
|
||||
"max_area": 512 * 512,
|
||||
"pad_outpaint": False
|
||||
}
|
||||
|
||||
response = self.client.post("/multi-ref", json=payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIn("job_id", data)
|
||||
job_id = data["job_id"]
|
||||
|
||||
# Poll status until done (limit to 300 seconds to prevent hanging if backend is offline)
|
||||
print(f"[test] Polling multi-ref job {job_id} until completion...")
|
||||
start_time = time.time()
|
||||
completed = False
|
||||
|
||||
while time.time() - start_time < 300:
|
||||
status_resp = self.client.get(f"/batch/{job_id}")
|
||||
if status_resp.status_code == 200:
|
||||
job_data = status_resp.json()
|
||||
status = job_data.get("status")
|
||||
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
|
||||
if status == "done":
|
||||
completed = True
|
||||
break
|
||||
elif status in ["error", "cancelled"]:
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
self.assertTrue(completed, "Multi-ref generation job did not complete successfully in time.")
|
||||
|
||||
def test_scenery_qwen_endpoint(self):
|
||||
"""Test scenery generation endpoint with specified wireframe scene image and prompt."""
|
||||
if not self.run_real_tests:
|
||||
self.skipTest("Skipping real backend test due to missing real image artifacts on this node.")
|
||||
|
||||
payload = {
|
||||
"model_filename": self.real_img_1,
|
||||
"scene_image": self.wireframe_img,
|
||||
"prompt": "replace person from image 1 naturally with the person from Image 2. Keep the exact position of Image 1, and the exact person of Image 2",
|
||||
"seed": 42
|
||||
}
|
||||
|
||||
response = self.client.post("/generate-scenery", json=payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIn("job_id", data)
|
||||
job_id = data["job_id"]
|
||||
|
||||
# Poll status until done
|
||||
print(f"[test] Polling scenery job {job_id} until completion...")
|
||||
start_time = time.time()
|
||||
completed = False
|
||||
|
||||
while time.time() - start_time < 300:
|
||||
status_resp = self.client.get(f"/batch/{job_id}")
|
||||
if status_resp.status_code == 200:
|
||||
job_data = status_resp.json()
|
||||
status = job_data.get("status")
|
||||
print(f"[test] Job status: {status} (done: {job_data.get('done')}/{job_data.get('total')})")
|
||||
if status == "done":
|
||||
completed = True
|
||||
break
|
||||
elif status in ["error", "cancelled"]:
|
||||
break
|
||||
time.sleep(3)
|
||||
|
||||
self.assertTrue(completed, "Scenery generation job did not complete successfully in time.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,329 +0,0 @@
|
||||
import os
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
import time
|
||||
import json
|
||||
import shutil
|
||||
import requests
|
||||
from PIL import Image
|
||||
import logging
|
||||
import hashlib
|
||||
import sys
|
||||
import fcntl
|
||||
import re
|
||||
|
||||
try:
|
||||
from . import database
|
||||
from . import embeddings
|
||||
except ImportError:
|
||||
import database
|
||||
import embeddings
|
||||
|
||||
# Load configuration
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
|
||||
|
||||
def load_config():
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
conf = json.load(f)
|
||||
# Resolve relative paths relative to this script's directory
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
for key in ["stage_dir", "output_dir", "failed_dir", "processed_file", "log_file"]:
|
||||
if not os.path.isabs(conf[key]):
|
||||
conf[key] = os.path.normpath(os.path.join(base_dir, "..", conf[key]))
|
||||
return conf
|
||||
|
||||
CONF = load_config()
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(CONF["log_file"]),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
|
||||
def get_processed_files():
|
||||
if os.path.exists(CONF["processed_file"]):
|
||||
try:
|
||||
with open(CONF["processed_file"], 'r') as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
# Migration: convert old list format to dict
|
||||
return {name: None for name in data}
|
||||
return data
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading processed file: {e}")
|
||||
return {}
|
||||
return {}
|
||||
|
||||
def save_processed_files(processed):
|
||||
try:
|
||||
with open(CONF["processed_file"], 'w') as f:
|
||||
json.dump(processed, f, indent=2)
|
||||
except Exception as e:
|
||||
logging.error(f"Error saving processed file: {e}")
|
||||
|
||||
def calculate_hash(filepath):
|
||||
"""Calculate MD5 hash of a file."""
|
||||
hasher = hashlib.md5()
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
except Exception as e:
|
||||
logging.error(f"Error calculating hash for {filepath}: {e}")
|
||||
return None
|
||||
|
||||
def crop_to_bbox(image_path, margin, top_margin=None, headroom=0.0):
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
if img.mode != 'RGBA':
|
||||
logging.info(f"Image {image_path} is mode {img.mode}, not RGBA. Skipping crop.")
|
||||
return img
|
||||
|
||||
alpha = img.split()[-1]
|
||||
bbox = alpha.getbbox()
|
||||
if not bbox:
|
||||
logging.info(f"No non-transparent bbox found for {image_path}. Returning original.")
|
||||
return img
|
||||
|
||||
if top_margin is None:
|
||||
top_margin = margin
|
||||
|
||||
# Add margin
|
||||
left, upper, right, lower = bbox
|
||||
left = max(0, left - margin)
|
||||
upper = max(0, upper - top_margin)
|
||||
right = min(img.width, right + margin)
|
||||
lower = min(img.height, lower + margin)
|
||||
|
||||
logging.info(f"Cropping {image_path} to {left, upper, right, lower} (margin={margin}, top_margin={top_margin})")
|
||||
cropped = img.crop((left, upper, right, lower))
|
||||
|
||||
if headroom > 0:
|
||||
h_px = int(cropped.height * headroom)
|
||||
if h_px > 0:
|
||||
logging.info(f"Adding {h_px}px headroom to {image_path}")
|
||||
new_img = Image.new("RGBA", (cropped.width, cropped.height + h_px), (0, 0, 0, 0))
|
||||
new_img.paste(cropped, (0, h_px))
|
||||
return new_img
|
||||
|
||||
return cropped
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to crop {image_path}: {e}")
|
||||
raise
|
||||
|
||||
def is_file_stable(filepath):
|
||||
"""Check if file size is stable for at least 1 second."""
|
||||
try:
|
||||
size1 = os.path.getsize(filepath)
|
||||
time.sleep(1)
|
||||
size2 = os.path.getsize(filepath)
|
||||
return size1 == size2 and size1 > 0
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def flag_image(filename):
|
||||
input_path = os.path.join(CONF["stage_dir"], filename)
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
failed_filename = f"{timestamp}_{filename}"
|
||||
failed_path = os.path.join(CONF["failed_dir"], failed_filename)
|
||||
try:
|
||||
os.makedirs(CONF["failed_dir"], exist_ok=True)
|
||||
logging.info(f"Flagging image {filename} (moving to failed directory as {failed_filename})")
|
||||
shutil.move(input_path, failed_path)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to move {filename} to failed directory: {e}")
|
||||
|
||||
def process_image(filename):
|
||||
# Reload config in case it changed
|
||||
global CONF
|
||||
try:
|
||||
CONF = load_config()
|
||||
except:
|
||||
pass
|
||||
|
||||
input_path = os.path.join(CONF["stage_dir"], filename)
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
output_filename = f"{timestamp}_{filename}"
|
||||
output_path = os.path.join(CONF["output_dir"], output_filename)
|
||||
|
||||
temp_path = input_path + ".tmp.png"
|
||||
try:
|
||||
logging.info(f"Starting processing for {filename}...")
|
||||
cropped_img = crop_to_bbox(
|
||||
input_path,
|
||||
CONF["margin"],
|
||||
top_margin=CONF.get("top_margin"),
|
||||
headroom=CONF.get("headroom", 0.0)
|
||||
)
|
||||
|
||||
# Save temporary cropped image for upload
|
||||
cropped_img.save(temp_path, format="PNG")
|
||||
|
||||
prompt = CONF.get("prompt")
|
||||
if not prompt:
|
||||
bp = CONF.get("base_prompts", [])
|
||||
if bp and isinstance(bp, list) and len(bp) > 0:
|
||||
prompt = bp[0]
|
||||
else:
|
||||
prompt = "high quality, masterpiece"
|
||||
|
||||
with open(temp_path, 'rb') as f:
|
||||
files = {'image': (filename, f, 'image/png')}
|
||||
data = {
|
||||
'prompt': prompt,
|
||||
'seed': CONF.get("seed", -1),
|
||||
'max_area': CONF.get("max_area", 0)
|
||||
}
|
||||
logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {prompt}")
|
||||
response = requests.post(CONF["api_url"], files=files, data=data, timeout=600)
|
||||
|
||||
if response.status_code == 200:
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
logging.info(f"Successfully processed {filename} -> {output_path}")
|
||||
|
||||
# Register in DB
|
||||
try:
|
||||
embedding = embeddings.generate_embedding(output_path)
|
||||
gid = filename
|
||||
database.upsert_person(output_filename, filepath=output_path, embedding=embedding, group_id=gid, is_source=True)
|
||||
|
||||
# Also trigger tagging to get auto-name and clip description
|
||||
tag_url = CONF["api_url"].replace("/edit", "/tag")
|
||||
try:
|
||||
requests.post(tag_url, json={"filename": output_filename, "group_id": gid}, timeout=30)
|
||||
except Exception as tag_err:
|
||||
logging.error(f"Error triggering tagging for {output_filename}: {tag_err}")
|
||||
except Exception as db_err:
|
||||
logging.error(f"Database error registering {output_filename}: {db_err}")
|
||||
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return True
|
||||
else:
|
||||
logging.error(f"API failed for {filename}: {response.status_code} - {response.text}")
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return False
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logging.error(f"Connection error while processing {filename}: {e}")
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Error processing {filename}: {str(e)}", exc_info=True)
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return False
|
||||
|
||||
def update_car_html():
|
||||
output_dir = CONF["output_dir"]
|
||||
car_html_path = os.path.join(output_dir, "car.html")
|
||||
if not os.path.exists(car_html_path):
|
||||
logging.warning(f"car.html not found at {car_html_path}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Use database to list only non-archived images
|
||||
persons = database.list_persons(include_archived=False)
|
||||
db_images = {p[0] for p in persons}
|
||||
|
||||
# List images in output_dir
|
||||
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
|
||||
images = [f for f in os.listdir(output_dir)
|
||||
if f.lower().endswith(extensions) and f != "car.html" and f in db_images]
|
||||
|
||||
# Sort by mtime, newest first
|
||||
images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
|
||||
|
||||
with open(car_html_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
images_json = json.dumps(images, indent=12).strip()
|
||||
# Ensure it looks nice in the JS
|
||||
images_json = images_json.replace('\n', '\n ')
|
||||
|
||||
pattern = r'// --- HYDRATION_START ---.*?// --- HYDRATION_END ---'
|
||||
replacement = f'// --- HYDRATION_START ---\n const PRELOADED_IMAGES = {images_json};\n // --- HYDRATION_END ---'
|
||||
|
||||
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
|
||||
|
||||
with open(car_html_path, 'w') as f:
|
||||
f.write(new_content)
|
||||
logging.info(f"Updated {car_html_path} with {len(images)} images")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to update car.html: {e}")
|
||||
|
||||
def main():
|
||||
# Prevent multiple instances
|
||||
lock_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "watcher.lock")
|
||||
fp = open(lock_file, 'w')
|
||||
try:
|
||||
fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except IOError:
|
||||
print("Another instance of watcher.py is already running. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
processed = get_processed_files()
|
||||
|
||||
# Ensure directories exist
|
||||
os.makedirs(CONF["stage_dir"], exist_ok=True)
|
||||
os.makedirs(CONF["output_dir"], exist_ok=True)
|
||||
os.makedirs(CONF["failed_dir"], exist_ok=True)
|
||||
|
||||
logging.info(f"Watcher started. Monitoring {CONF['stage_dir']}...")
|
||||
logging.info(f"Output directory: {CONF['output_dir']}")
|
||||
logging.info(f"API URL: {CONF['api_url']}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
files = [f for f in os.listdir(CONF["stage_dir"])
|
||||
if f.lower().endswith(('.png', '.jpg', '.jpeg'))
|
||||
and not f.endswith('.tmp.png')]
|
||||
|
||||
for f in files:
|
||||
input_path = os.path.join(CONF["stage_dir"], f)
|
||||
|
||||
# Check if file is stable (not still being copied)
|
||||
if not is_file_stable(input_path):
|
||||
continue
|
||||
|
||||
# Calculate current file hash
|
||||
current_hash = calculate_hash(input_path)
|
||||
if not current_hash:
|
||||
continue
|
||||
|
||||
# Check if already processed
|
||||
if f in processed:
|
||||
stored_hash = processed[f]
|
||||
if stored_hash == current_hash:
|
||||
continue
|
||||
if stored_hash is None:
|
||||
# Migration case: filename exists but no hash.
|
||||
# Skip to avoid mass re-processing, but update the hash.
|
||||
logging.info(f"Updating hash for previously processed {f}")
|
||||
processed[f] = current_hash
|
||||
save_processed_files(processed)
|
||||
continue
|
||||
|
||||
res = process_image(f)
|
||||
if res is True:
|
||||
processed[f] = current_hash
|
||||
save_processed_files(processed)
|
||||
update_car_html()
|
||||
elif res is False:
|
||||
flag_image(f)
|
||||
# We don't add to processed here so that if the user
|
||||
# moves the file back to stage, it will be retried.
|
||||
except Exception as e:
|
||||
logging.error(f"Main loop error: {e}")
|
||||
|
||||
time.sleep(CONF["poll_interval"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user