updates UI

This commit is contained in:
mike
2026-07-01 02:25:51 +02:00
parent 66685684c1
commit 145fa686e4
22 changed files with 1559 additions and 159 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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"

View File

@@ -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(

View File

@@ -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

View File

@@ -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."

View File

@@ -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."

View File

@@ -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.

View File

@@ -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 \
"$@"

View File

@@ -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

View File

@@ -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

View File

@@ -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."

View 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()

View File

@@ -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()